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"] == "先继续整理语义上下文,不要直接执行。"