PMFlow/backend/tests/test_mounted_ai_runtime_wri...

321 lines
11 KiB
Python

from __future__ import annotations
import os
import pytest
import pytest_asyncio
from sqlalchemy import JSON, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
from app.models.base import Base
from app.models.mounted_ai_runtime import (
MountedAgentTrace,
MountedConversationRun,
MountedSourceActivation,
MountedTopicWorkspace,
)
from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
def _use_sqlite_json_types() -> None:
for table in (
MountedTopicWorkspace.__table__,
MountedConversationRun.__table__,
MountedAgentTrace.__table__,
MountedSourceActivation.__table__,
):
for column in table.columns:
if column.name in {
"facts_json",
"goals_json",
"constraints_json",
"options_json",
"open_questions",
"pending_actions",
"bound_entities",
"metadata_json",
"context_layers_hit",
"tool_calls",
"execution_outcome",
"input_summary",
"output_summary",
"tool_trace",
}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
MountedTopicWorkspace.__table__,
MountedConversationRun.__table__,
MountedAgentTrace.__table__,
MountedSourceActivation.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_begin_ingress_run_uses_thread_anchor_and_sets_runtime_context(db_session) -> None:
db_session.add(
MountedSourceActivation(
tenant_id=1,
source_system="feishu",
scope_type="chat",
scope_ref="chat_1",
status="active",
cutover_mode="mounted_only",
auto_reply_enabled=True,
)
)
await db_session.commit()
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_1",
message_text="帮我复盘一下这个直播需求。",
sender="林宇航",
channel_ref="chat_1",
thread_context={
"thread_id": "thread_1",
"root_message_id": "om_root_1",
"reply_to_message_id": "om_reply_1",
},
)
await db_session.commit()
saved_topic = (
await db_session.execute(select(MountedTopicWorkspace))
).scalar_one()
saved_run = (
await db_session.execute(select(MountedConversationRun))
).scalar_one()
assert tracking.topic_workspace.id == saved_topic.id
assert saved_topic.topic_key == "feishu:chat_1:root:om_root_1"
assert saved_topic.thread_key == "thread_1"
assert saved_topic.last_message_id == "om_message_1"
assert saved_run.status == "running"
assert saved_run.mode_before is None
assert saved_run.context_layers_hit == {
"current_message": True,
"thread": True,
"topic": False,
"source_activation": True,
}
assert saved_topic.metadata_json["source_activation"]["cutover_mode"] == "mounted_only"
assert saved_run.trigger_reason == "ingress:feishu:mounted_only"
@pytest.mark.asyncio
async def test_record_decision_and_finalize_run_persist_topic_mode_and_traces(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_2",
message_text="分配给谁做比较合适?",
sender="林宇航",
channel_ref="chat_2",
thread_context={"root_message_id": "om_root_2"},
)
await service.record_orchestrator_decision(
tracking=tracking,
workflow_route="project_signal.create",
intent_name="signal_create",
intent_confidence=0.82,
trigger_reason="manual_review",
auto_execute=False,
work_item_id=321,
route_metadata={"workflow_family": "project_signal", "workflow_action": "create"},
)
await service.finalize_run(
tracking=tracking,
result={
"handled": True,
"status": "captured",
"classification": "command",
"work_item_id": 321,
"workflow_route": "project_signal.create",
"direct_reply_text": "我先帮你收敛需求,再给负责人建议。",
},
)
await db_session.commit()
saved_topic = (
await db_session.execute(select(MountedTopicWorkspace))
).scalar_one()
saved_run = (
await db_session.execute(select(MountedConversationRun))
).scalar_one()
traces = (
await db_session.execute(
select(MountedAgentTrace).order_by(MountedAgentTrace.id.asc())
)
).scalars().all()
assert saved_topic.mode == "plan"
assert saved_topic.metadata_json["last_workflow_route"] == "project_signal.create"
assert saved_run.mode_after == "plan"
assert saved_run.status == "completed"
assert saved_run.final_reply == "我先帮你收敛需求,再给负责人建议。"
assert len(traces) == 2
assert traces[0].agent_name == "ConversationOrchestrator"
assert traces[1].agent_name == "ReplyComposer"
@pytest.mark.asyncio
async def test_begin_ingress_run_skips_unmatched_chat_when_source_activation_whitelist_exists(db_session) -> None:
db_session.add(
MountedSourceActivation(
tenant_id=1,
source_system="feishu",
scope_type="chat",
scope_ref="whitelisted_chat",
status="active",
cutover_mode="mounted_only",
auto_reply_enabled=True,
)
)
await db_session.commit()
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_3",
message_text="这个 chat 不在白名单里。",
sender="林宇航",
channel_ref="non_whitelisted_chat",
thread_context={"root_message_id": "om_root_3"},
)
assert tracking is None
topic_rows = (await db_session.execute(select(MountedTopicWorkspace))).scalars().all()
run_rows = (await db_session.execute(select(MountedConversationRun))).scalars().all()
assert topic_rows == []
assert run_rows == []
@pytest.mark.asyncio
async def test_begin_ingress_run_persists_conversation_scope_key_for_feishu(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_4",
message_text="想跟你聊一个新需求。",
sender="林宇航",
channel_ref="chat_scope_1",
thread_context=None,
source_sender_open_id="ou_scope_1",
)
await db_session.commit()
saved_topic = (
await db_session.execute(select(MountedTopicWorkspace))
).scalar_one()
assert tracking is not None
assert saved_topic.conversation_scope_key == "feishu:chat_scope_1:ou_scope_1"
assert saved_topic.plan_status == "idle"
@pytest.mark.asyncio
async def test_update_topic_plan_state_persists_explicit_plan_status_and_pending_actions(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_5",
message_text="先聊需求,不直接创建。",
sender="林宇航",
channel_ref="chat_plan_state",
thread_context=None,
source_sender_open_id="ou_plan_state",
)
await service.update_topic_plan_state(
tracking=tracking,
plan_status="awaiting_execute_confirm",
topic_status="active",
pending_actions=["confirm_requirement_draft"],
metadata={"plan_topic_action": "draft_ready"},
)
await db_session.commit()
saved_topic = (
await db_session.execute(select(MountedTopicWorkspace))
).scalar_one()
assert saved_topic.plan_status == "awaiting_execute_confirm"
assert saved_topic.pending_actions == ["confirm_requirement_draft"]
assert saved_topic.metadata_json["plan_topic_action"] == "draft_ready"
@pytest.mark.asyncio
async def test_record_semantic_shadow_merges_with_finalize_run_execution_outcome(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_6",
message_text="先继续整理语义上下文,不要直接执行。",
sender="林宇航",
channel_ref="chat_semantic_shadow",
thread_context={"root_message_id": "om_root_6"},
source_sender_open_id="ou_semantic_shadow",
)
await service.record_semantic_shadow(
tracking=tracking,
source_message_preview="先继续整理语义上下文,不要直接执行。",
semantic_packet_summary={"packet_version": "v1", "recent_turn_count": 2},
semantic_decision={"topic_relation": "continue_current_topic", "mode_decision": "stay_plan"},
semantic_gate_result={"accepted": True, "next_plan_status": "collecting"},
semantic_diff={"changed": True, "legacy_route": "query.answer"},
)
await service.finalize_run(
tracking=tracking,
result={
"handled": True,
"status": "planned",
"classification": "query",
"work_item_id": 654,
"workflow_route": "query.answer",
"direct_reply_text": "我先把语义上下文整理成 shadow 观测结果。",
},
)
await db_session.commit()
saved_topic = (await db_session.execute(select(MountedTopicWorkspace))).scalar_one()
saved_run = (await db_session.execute(select(MountedConversationRun))).scalar_one()
assert saved_topic.plan_status == "collecting"
assert saved_run.execution_outcome["handled"] is True
assert saved_run.execution_outcome["semantic_packet_summary"]["packet_version"] == "v1"
assert saved_run.execution_outcome["semantic_decision"]["mode_decision"] == "stay_plan"
assert saved_run.execution_outcome["semantic_gate_result"]["next_plan_status"] == "collecting"
assert saved_run.execution_outcome["semantic_diff"]["legacy_route"] == "query.answer"
assert saved_run.execution_outcome["source_message_preview"] == "先继续整理语义上下文,不要直接执行。"