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.feishu_reply_outbox import FeishuReplyOutbox 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__, FeishuReplyOutbox.__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_begin_ingress_run_reuses_canonical_source_topic_for_feishu_bot_reply_anchor(db_session) -> None: existing_topic = MountedTopicWorkspace( tenant_id=1, topic_key="feishu:chat_reply_canonical:root:om_source_1", source_system="feishu", channel_ref="chat_reply_canonical", root_message_id="om_source_1", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_topic_action": "collecting"}, ) db_session.add(existing_topic) db_session.add( FeishuReplyOutbox( tenant_id=1, trace_id="trace_reply_canonical", source_message_id="om_source_1", sent_message_id="om_bot_reply_1", chat_id="chat_reply_canonical", message_text="上一轮 requirement-plan 选项", status="sent", attempts=1, ) ) await db_session.commit() service = MountedAIRuntimeWriteService(db_session) tracking = await service.begin_ingress_run( tenant_id=1, source_system="feishu", source_message_id="om_user_reply_1", message_text="C", sender="林宇航", channel_ref="chat_reply_canonical", thread_context={ "root_message_id": "om_bot_reply_1", "reply_to_message_id": "om_bot_reply_1", }, source_sender_open_id="ou_reply_canonical", ) await db_session.commit() topic_rows = ( await db_session.execute( select(MountedTopicWorkspace).order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() saved_run = ( await db_session.execute(select(MountedConversationRun)) ).scalar_one() assert tracking is not None assert len(topic_rows) == 1 assert tracking.topic_workspace.id == int(existing_topic.id) assert tracking.topic_workspace.topic_key == "feishu:chat_reply_canonical:root:om_source_1" assert tracking.topic_workspace.root_message_id == "om_source_1" assert saved_run.topic_workspace_id == int(existing_topic.id) assert saved_run.context_layers_hit == { "current_message": True, "thread": True, "topic": True, "source_activation": False, } @pytest.mark.asyncio async def test_begin_ingress_run_reuses_existing_source_topic_for_direct_reply_anchor(db_session) -> None: existing_topic = MountedTopicWorkspace( tenant_id=1, topic_key="feishu:chat_reply_source:message:om_source_2", source_system="feishu", channel_ref="chat_reply_source", root_message_id="om_source_2", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) db_session.add(existing_topic) await db_session.commit() service = MountedAIRuntimeWriteService(db_session) tracking = await service.begin_ingress_run( tenant_id=1, source_system="feishu", source_message_id="om_user_reply_2", message_text="工单数据来自禅道部署工单。", sender="林宇航", channel_ref="chat_reply_source", thread_context={ "root_message_id": "om_source_2", "reply_to_message_id": "om_source_2", }, source_sender_open_id="ou_reply_source", ) await db_session.commit() topic_rows = ( await db_session.execute( select(MountedTopicWorkspace).order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() saved_run = ( await db_session.execute(select(MountedConversationRun)) ).scalar_one() assert tracking is not None assert len(topic_rows) == 1 assert tracking.topic_workspace.id == int(existing_topic.id) assert tracking.topic_workspace.topic_key == "feishu:chat_reply_source:message:om_source_2" assert tracking.topic_workspace.root_message_id == "om_source_2" assert saved_run.topic_workspace_id == int(existing_topic.id) assert saved_run.context_layers_hit == { "current_message": True, "thread": True, "topic": True, "source_activation": False, } @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_record_orchestrator_decision_preserves_requirement_plan_mode_for_query_answer_turns(db_session) -> None: service = MountedAIRuntimeWriteService(db_session) tracking = await service.begin_ingress_run( tenant_id=1, source_system="feishu", source_message_id="om_message_plan_preserve", message_text="C", sender="林宇航", channel_ref="chat_plan_preserve", thread_context={"root_message_id": "om_root_plan_preserve"}, source_sender_open_id="ou_plan_preserve", ) assert tracking is not None tracking.topic_workspace.mode = "plan" tracking.topic_workspace.plan_status = "options_ready" tracking.topic_workspace.metadata_json = { **(tracking.topic_workspace.metadata_json if isinstance(tracking.topic_workspace.metadata_json, dict) else {}), "plan_mode": True, } await db_session.flush() await service.record_orchestrator_decision( tracking=tracking, workflow_route="query.answer", intent_name="option_selection", intent_confidence=0.93, trigger_reason="reply_anchor_followup", auto_execute=False, work_item_id=654, route_metadata={"message_intent": "requirement_plan"}, ) 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.mode == "plan" assert saved_run.mode_after == "plan" assert saved_topic.metadata_json["last_workflow_route"] == "query.answer" @pytest.mark.asyncio async def test_record_orchestrator_decision_ignores_chat_mode_override_when_requirement_plan_context_exists(db_session) -> None: service = MountedAIRuntimeWriteService(db_session) tracking = await service.begin_ingress_run( tenant_id=1, source_system="feishu", source_message_id="om_message_plan_override_preserve", message_text="补充一个需求事实。", sender="林宇航", channel_ref="chat_plan_override_preserve", thread_context={"root_message_id": "om_root_plan_override_preserve"}, source_sender_open_id="ou_plan_override_preserve", ) assert tracking is not None tracking.topic_workspace.mode = "plan" tracking.topic_workspace.plan_status = "collecting" tracking.topic_workspace.pending_actions = ["continue_requirement_plan"] tracking.topic_workspace.metadata_json = { **(tracking.topic_workspace.metadata_json if isinstance(tracking.topic_workspace.metadata_json, dict) else {}), "plan_mode": True, } await db_session.flush() await service.record_orchestrator_decision( tracking=tracking, workflow_route="query.answer", intent_name="contextual_fact_addition", intent_confidence=0.95, trigger_reason="reply_anchor_followup", auto_execute=False, work_item_id=655, route_metadata={"message_intent": "contextual_fact_addition"}, mode_override="chat", ) 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.mode == "plan" assert saved_run.mode_after == "plan" @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 == "idle" 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"] == "先继续整理语义上下文,不要直接执行。" @pytest.mark.asyncio async def test_finalize_run_persists_requirement_plan_context_and_rag_result(db_session) -> None: service = MountedAIRuntimeWriteService(db_session) tracking = await service.begin_ingress_run( tenant_id=1, source_system="feishu", source_message_id="om_message_plan_context_1", message_text="确认A,继续往下收。", sender="林宇航", channel_ref="chat_plan_context", thread_context={"root_message_id": "om_root_plan_context_1"}, source_sender_open_id="ou_plan_context_1", ) await service.finalize_run( tracking=tracking, result={ "handled": True, "status": "answered", "classification": "question", "work_item_id": 901, "workflow_route": "query.answer", "direct_reply_text": "已确认选项 A,继续收下一轮信息。", "rag_result": { "answer": "已确认选项 A,继续收下一轮信息。", "query_type": "plan", }, "requirement_plan_context": { "question_round": 2, "last_round_options": { "field": "target_user", "options": [ {"key": "A", "title": "单一层级区域经理"}, {"key": "B", "title": "区域经理 + 店长"}, {"key": "C", "title": "总部 + 区域 + 门店多层级"}, ], "recommendation": {"key": "A"}, }, "round_option_maps": { "target_user": { "A": "单一层级区域经理", "B": "区域经理 + 店长", "C": "总部 + 区域 + 门店多层级", } }, }, }, ) await db_session.commit() saved_run = (await db_session.execute(select(MountedConversationRun))).scalar_one() assert saved_run.final_reply == "已确认选项 A,继续收下一轮信息。" assert saved_run.execution_outcome["rag_result"]["query_type"] == "plan" assert saved_run.execution_outcome["requirement_plan_context"]["question_round"] == 2 assert saved_run.execution_outcome["requirement_plan_context"]["last_round_options"]["field"] == "target_user" @pytest.mark.asyncio async def test_finalize_run_reconciles_semantic_packet_summary_requirement_card_with_requirement_plan_context( db_session, ) -> None: service = MountedAIRuntimeWriteService(db_session) tracking = await service.begin_ingress_run( tenant_id=1, source_system="feishu", source_message_id="om_message_plan_context_sync", message_text="确认A,继续往下收。", sender="林宇航", channel_ref="chat_plan_context_sync", thread_context={"root_message_id": "om_root_plan_context_sync"}, source_sender_open_id="ou_plan_context_sync", ) tracking.conversation_run.execution_outcome = { "semantic_packet_summary": { "draft": { "requirement_card": { "project_nature": "轻量工具型", "target_user": "轻量工具型", "question_round": 3, } } } } await service.finalize_run( tracking=tracking, result={ "handled": True, "status": "answered", "classification": "question", "workflow_route": "query.answer", "direct_reply_text": "继续收敛第一版范围。", "requirement_plan_context": { "project_nature": "轻量定时简报", "target_user": "单一层级区域经理", "question_round": 4, "last_round_options": { "field": "first_scope", "options": [ {"key": "A", "title": "全量门店+核心异常类型"}, {"key": "B", "title": "单城市试点+全异常维度"}, {"key": "C", "title": "TOP30高动销门店+时效性异常"}, ], "recommendation": {"key": "A"}, }, }, }, ) await db_session.commit() saved_run = (await db_session.execute(select(MountedConversationRun))).scalar_one() packet_card = saved_run.execution_outcome["semantic_packet_summary"]["draft"]["requirement_card"] assert packet_card["project_nature"] == "轻量定时简报" assert packet_card["target_user"] == "单一层级区域经理" assert packet_card["question_round"] == 4 assert packet_card["last_round_options"]["field"] == "first_scope" @pytest.mark.asyncio async def test_finalize_run_rebinds_conversation_run_to_canonical_draft_artifact_topic(db_session) -> None: service = MountedAIRuntimeWriteService(db_session) tracking = await service.begin_ingress_run( tenant_id=1, source_system="feishu", source_message_id="om_message_plan_canonical_rebind", message_text="想聊个新需求。", sender="林宇航", channel_ref="chat_plan_canonical_rebind", thread_context=None, source_sender_open_id="ou_plan_canonical_rebind", ) assert tracking is not None ingress_topic_id = int(tracking.topic_workspace.id) canonical_topic = MountedTopicWorkspace( tenant_id=1, topic_key="feishu:chat_plan_canonical_rebind:message:om_existing_requirement_root", source_system="feishu", channel_ref="chat_plan_canonical_rebind", conversation_scope_key="feishu:chat_plan_canonical_rebind:ou_plan_canonical_rebind", root_message_id=None, mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) db_session.add(canonical_topic) await db_session.flush() await service.finalize_run( tracking=tracking, result={ "handled": True, "status": "captured", "classification": "command", "workflow_route": "project_create.create", "direct_reply_text": "已整理成草稿,等待确认。", "reply_template_key": "draft_pending_confirm", "draft_artifact": { "type": "requirement_plan_draft", "topic_workspace_id": int(canonical_topic.id), "draft_workspace_id": 36, "draft_version": 1, "promote_to_confirmation": True, }, }, ) await db_session.commit() 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 int(saved_run.topic_workspace_id) == int(canonical_topic.id) assert int(saved_run.topic_workspace_id) != ingress_topic_id assert saved_run.execution_outcome["draft_artifact"]["topic_workspace_id"] == int(canonical_topic.id) assert traces[-1].agent_name == "ReplyComposer"