from __future__ import annotations import os from types import SimpleNamespace import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy import JSON from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test" from app.api.ai_steward_api import routes_mounted_ai from app.core.database import get_db from app.dependencies.auth import get_current_user from app.dependencies.tenant import get_tenant_id from app.models.base import Base from app.models.mounted_ai_runtime import ( MountedAgentTrace, MountedConversationRun, MountedDraftWorkspace, MountedTopicWorkspace, ) def _use_sqlite_json_types() -> None: for table in ( MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, MountedConversationRun.__table__, MountedAgentTrace.__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", "requirement_card", "draft_payload", "required_confirmations", "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__, MountedDraftWorkspace.__table__, MountedConversationRun.__table__, MountedAgentTrace.__table__, ], ) ) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() def _build_app(db_session): app = FastAPI() app.include_router(routes_mounted_ai.router) async def _fake_get_db(): yield db_session app.dependency_overrides[get_db] = _fake_get_db app.dependency_overrides[get_tenant_id] = lambda: 1 app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( id=9001, tenant_id=1, role="ADMIN", name="管理员", username="admin", ) return app @pytest.mark.asyncio async def test_mounted_runtime_run_detail_exposes_semantic_shadow_fields(db_session) -> None: topic = MountedTopicWorkspace( tenant_id=1, topic_key="feishu:chat_1:root:om_root_1", source_system="feishu", channel_ref="chat_1", thread_key="thread_1", mode="plan", status="active", plan_status="collecting", facts_json={"title": "语义上下文迁移"}, open_questions=["是否切新 topic"], metadata_json={"last_message_preview": "这次继续统一语义入口"}, ) db_session.add(topic) await db_session.flush() draft = MountedDraftWorkspace( tenant_id=1, topic_workspace_id=int(topic.id), state="collecting", draft_version=3, requirement_card={"goal": "统一语义入口"}, draft_payload={"draft_text": "先做 shadow mode"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) db_session.add(draft) await db_session.flush() run = MountedConversationRun( tenant_id=1, trace_id="mounted:feishu:semantic-detail", topic_workspace_id=int(topic.id), source_system="feishu", source_message_id="om_message_1", mode_before="plan", mode_after="plan", active_agent="ConversationOrchestrator", status="completed", trigger_reason="shadow", context_layers_hit={"topic": True, "thread": True}, execution_outcome={ "source_message_preview": "这轮继续按 OpenCow 风格整理语义上下文", "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"}, }, final_reply="我先把上下文整理出来。", ) db_session.add(run) await db_session.commit() app = _build_app(db_session) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.get(f"/ai-steward/mounted-ai/runs/{run.id}") assert resp.status_code == 200 payload = resp.json()["data"] assert payload["semantic_packet_summary"]["packet_version"] == "v1" assert payload["semantic_decision"]["mode_decision"] == "stay_plan" assert payload["semantic_gate_result"]["accepted"] is True assert payload["semantic_diff"]["legacy_route"] == "query.answer" assert payload["topic"]["topic_key"] == "feishu:chat_1:root:om_root_1" assert payload["draft"]["draft_version"] == 3