"""Persistence coverage for the standalone position-collaboration workflow.""" from __future__ import annotations import asyncio from types import SimpleNamespace from typing import Any import pytest import pytest_asyncio from fastapi import HTTPException from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.gateway import deps as gateway_deps from app.gateway import services as gateway_services from app.gateway.app import create_app from app.gateway.routers import intent as intent_router from app.gateway.routers import multi_agent from app.gateway.routers import position_roundtable as position_roundtable_router from deerflow.persistence.base import Base from deerflow.persistence.position_roundtable import PositionRoundtableRepository from deerflow.persistence.thread_meta.sql import ThreadMetaRepository @pytest_asyncio.fixture async def store(tmp_path): engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'position-roundtable.db'}") async with engine.begin() as conn: # Register both tables with Base.metadata before creating the temporary DB. import deerflow.persistence.position_roundtable.model # noqa: F401 await conn.run_sync(Base.metadata.create_all) return PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False)) def test_reject_node_request_allows_an_optional_reason(): """A rejection must work even when the reviewer leaves the reason blank.""" request = position_roundtable_router.RejectNodeRequest() assert request.title == "产物驳回" assert request.reason == "" def test_position_roundtable_router_has_intranet_compatibility_prefix(): """The workspace must survive proxies that only expose /api/multi-agent.""" app = create_app() registrations = { (route.include_context.prefix, route.include_context.include_in_schema) for route in app.routes if getattr(route, "original_router", None) is position_roundtable_router.router } assert ("/api", True) in registrations assert ("/api/multi-agent", False) in registrations assert position_roundtable_router.router.prefix == "/position-roundtable" @pytest.mark.asyncio async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch): """Step 1 initialization must not depend on the deployed Gateway port.""" captured: dict[str, object] = {} monkeypatch.setattr(intent_router, "ensure_roundtable_functional_agents", lambda: None) async def fake_create_thread_direct(request, *, metadata): captured["request"] = request captured["metadata"] = metadata return "intent-thread-direct" monkeypatch.setattr(intent_router, "_create_thread_direct", fake_create_thread_direct) request = SimpleNamespace() result = await intent_router.init_intent( request, {"task_context": {"task": {"id": "task-001"}}}, ) assert result == { "status": "success", "agent_id": intent_router.INTENT_AGENT_ID, "thread_id": "intent-thread-direct", } assert captured["request"] is request assert captured["metadata"] == { **intent_router._ROUNDTABLE_THREAD_METADATA, "taskId": "task-001", "position_roundtable_task_id": "task-001", "position_task_id": "task-001:intelligence", "position_id": "intelligence", "position_roundtable_phase": "intent", } @pytest.mark.asyncio async def test_session_activation_freezes_chain_and_creates_nodes_once(store): created = await store.create_session( "user-a", external_task_id="task-1", task_snapshot={"title": "测试任务"}, ) await store.update_session( created["id"], "user-a", intent_thread_id="intent-thread", intent_snapshot={"objective": "识别意图"}, ) chain = { "id": "chain-a", "seats": [ {"agent_id": "agent-a", "position_id": "planning"}, {"agent_id": "agent-b", "position_id": "solution"}, ], } nodes = [ { "node_key": "stage-1-seat-1-agent-a", "stage_index": 0, "seat_index": 0, "agent_id": "agent-a", "position_id": "planning", "status": "ready", }, { "node_key": "stage-2-seat-1-agent-b", "stage_index": 1, "seat_index": 0, "agent_id": "agent-b", "position_id": "solution", "status": "locked", }, ] activated, created_nodes = await store.activate_session( created["id"], "user-a", chain_id="chain-a", chain_snapshot=chain, nodes=nodes ) assert activated["status"] == "active" assert activated["chain_snapshot"] == chain assert [node["status"] for node in created_nodes] == ["ready", "locked"] # Repeated activate calls must return the first frozen snapshot/nodes unchanged. repeated, repeated_nodes = await store.activate_session( created["id"], "user-a", chain_id="other", chain_snapshot={"id": "other"}, nodes=[] ) assert repeated["chain_id"] == "chain-a" assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes] @pytest.mark.asyncio async def test_conversation_snapshots_round_trip_and_history_delete_removes_nodes(store): """数据库恢复副本必须覆盖三个对话阶段、岗位节点和未发送草稿。""" created = await store.create_session( "user-a", external_task_id="task-persisted", task_snapshot={"title": "持久化测试"}, ) intent_conversation = { "messages": [{"id": "intent-user-1", "type": "human", "content": "请分析任务"}], "timeline": [{"kind": "message", "message_id": "intent-user-1"}], "draft": "尚未发送的意图补充", } summary_conversation = { "messages": [{"id": "summary-ai-1", "type": "ai", "content": "方案总结"}], "draft": "总结修改意见", } action_conversation = { "messages": [{"id": "action-ai-1", "type": "ai", "content": "行动规划"}], "draft": "", } updated = await store.update_session( created["id"], "user-a", intent_thread_id="intent-thread", intent_conversation=intent_conversation, summary_conversation=summary_conversation, action_plan_conversation=action_conversation, ) assert updated is not None assert updated["intent_conversation"] == intent_conversation assert updated["summary_conversation"] == summary_conversation assert updated["action_plan_conversation"] == action_conversation _, nodes = await store.activate_session( created["id"], "user-a", chain_id="chain-a", chain_snapshot={"id": "chain-a"}, nodes=[ { "node_key": "stage-1-seat-1-agent-a", "stage_index": 0, "seat_index": 0, "agent_id": "agent-a", "position_id": "planning", "status": "ready", } ], ) node_conversation = { "messages": [{"id": "node-ai-1", "type": "ai", "content": "岗位答复"}], "draft": "岗位草稿", } saved_node = await store.update_node( created["id"], nodes[0]["node_key"], "user-a", conversation_snapshot=node_conversation, ) assert saved_node is not None assert saved_node["conversation_snapshot"] == node_conversation # Ownership checks apply to delete as well. assert not await store.delete_session(created["id"], "user-b") assert await store.get_session(created["id"], "user-a") is not None assert await store.delete_session(created["id"], "user-a") assert await store.get_session(created["id"], "user-a") is None assert await store.list_nodes(created["id"], "user-a") is None @pytest.mark.asyncio async def test_demo_seed_is_complete_idempotent_and_user_scoped(store): payload = { "external_task_id": "position-demo-full-v1", "task_snapshot": {"title": "暑期活动"}, "chain_id": "position-demo-chain", "chain_snapshot": { "id": "position-demo-chain", "seats": [ { "agent_id": "demo-agent", "name": "演示分析智能体", "position_id": "planning", } ], }, "intent_thread_id": "frontend-placeholder", "intent_snapshot": { "objective": "形成完整宣传方案", "constraints": ["预算不少于 15 万元"], }, "intent_conversation": { "messages": [{"id": 1, "sender": "agent", "content": "意图已确认"}], "inputDraft": "", }, "summary_thread_id": "frontend-placeholder", "summary_snapshot": {"status": "done", "content": "方案总结"}, "summary_conversation": { "messages": [{"id": "summary-ai", "type": "ai", "content": "方案总结"}] }, "action_plan_thread_id": "frontend-placeholder", "action_plan_snapshot": {"status": "done", "content": "行动规划"}, "action_plan_conversation": { "messages": [{"id": "action-ai", "type": "ai", "content": "行动规划"}] }, "status": "completed", } nodes = [ { "node_key": "stage-1-seat-1-demo-agent", "stage_index": 0, "seat_index": 0, "agent_id": "demo-agent", "position_id": "planning", "thread_id": "frontend-placeholder", "status": "done", "latest_answer": "报告已完成", "artifact_manifest": [ { "name": "演示报告.md", "path": "mnt/user-data/outputs/演示报告.md", "mime_type": "text/markdown", "content": "# 演示报告\n\n完整正文", } ], "conversation_snapshot": { "messages": [{"id": "node-ai", "type": "ai", "content": "报告已完成"}], "inputDraft": "", }, "revision": 1, } ] first, first_nodes = await store.ensure_demo_session( "user-a", demo_key="full-v1", session_payload=payload, nodes=nodes, ) repeated, repeated_nodes = await store.ensure_demo_session( "user-a", demo_key="full-v1", session_payload={**payload, "task_snapshot": {"title": "不应覆盖"}}, nodes=[], ) other_user, other_nodes = await store.ensure_demo_session( "user-b", demo_key="full-v1", session_payload=payload, nodes=nodes, ) assert first["id"] == repeated["id"] assert first["task_snapshot"]["title"] == "暑期活动" assert repeated["task_snapshot"]["title"] == "暑期活动" assert first["intent_conversation"] == payload["intent_conversation"] assert first["summary_snapshot"]["thread_id"] == first["summary_thread_id"] assert first["action_plan_snapshot"]["thread_id"] == first["action_plan_thread_id"] assert first_nodes[0]["artifact_manifest"][0]["content"] == "# 演示报告\n\n完整正文" assert repeated_nodes[0]["id"] == first_nodes[0]["id"] assert other_user["id"] != first["id"] assert other_user["intent_thread_id"] != first["intent_thread_id"] assert other_nodes[0]["thread_id"] != first_nodes[0]["thread_id"] @pytest.mark.asyncio async def test_embedded_artifact_content_survives_missing_sandbox(monkeypatch): """后续岗位优先读取 SQL 正文,不能因沙箱卷丢失而只拿到文件名。""" async def unexpected_resolve(*_args, **_kwargs): raise AssertionError("embedded database content should avoid sandbox resolution") monkeypatch.setattr( position_roundtable_router, "aresolve_thread_virtual_path", unexpected_resolve, ) artifacts, consumed = await position_roundtable_router._artifact_refs_with_content( "removed-thread", [ { "name": "report.md", "path": "mnt/user-data/outputs/report.md", "mime_type": "text/markdown", "content": "# 数据库恢复报告\n\n完整正文", } ], 10_000, ) assert artifacts[0]["content"] == "# 数据库恢复报告\n\n完整正文" assert consumed == len("# 数据库恢复报告\n\n完整正文") def test_task_external_context_reaches_agent_runtime_context(): """岗位会商的左侧任务快照不能在网关白名单中丢失。""" task_context = { "task": { "title": "重点区域态势研判与协同处置方案", "description": "梳理风险信号并形成协同处置建议", } } config: dict = {"configurable": {}, "context": {}} gateway_services.merge_run_context_overrides( config, {"task_external_context": task_context}, ) assert config["configurable"]["task_external_context"] == task_context assert config["context"]["task_external_context"] == task_context def test_position_intent_thread_metadata_keeps_parent_and_child_task_ids(): metadata = intent_router._intent_thread_metadata( { "task": {"id": "task-100"}, "current_position": {"id": "intelligence"}, } ) assert metadata["taskId"] == "task-100" assert metadata["position_roundtable_task_id"] == "task-100" assert metadata["position_task_id"] == "task-100:intelligence" assert metadata["position_id"] == "intelligence" def test_chain_node_activation_rejects_missing_agent_ids(): chain = { "id": "chain-a", "seats": [ {"agent_id": "agent-a", "name": "有效智能体", "position_id": "planning"}, {"agent_id": "missing-agent", "name": "缺失智能体", "position_id": "solution"}, ], } with pytest.raises(HTTPException) as error: position_roundtable_router._chain_nodes( chain, available_agent_ids={"agent-a"}, ) assert error.value.status_code == 400 assert "缺失智能体" in str(error.value.detail) assert "缺少可用智能体" in str(error.value.detail) def test_intent_position_nodes_complete_from_confirmed_intent_and_unlock_next_stage(): nodes = [ { "node_key": "stage-1-seat-1-intent-agent", "stage_index": 0, "seat_index": 0, "agent_id": "intent-agent", "position_id": "intelligence", "status": "ready", }, { "node_key": "stage-2-seat-1-plan-agent", "stage_index": 1, "seat_index": 0, "agent_id": "plan-agent", "position_id": "planning", "status": "locked", }, ] primed = position_roundtable_router._prime_intent_nodes( nodes, {"objective": "识别用户想要形成协同方案", "constraints": ["两天内完成"]}, ) assert [node["status"] for node in primed] == ["done", "ready"] assert primed[0]["revision"] == 1 assert "识别用户想要形成协同方案" in primed[0]["latest_answer"] def test_all_nodes_delivered_requires_markdown_for_non_intent_nodes(): assert not position_roundtable_router._all_nodes_delivered( [ { "position_id": "planning", "status": "done", "artifact_manifest": [{"name": "plain.txt", "path": "mnt/user-data/outputs/plain.txt"}], } ] ) assert position_roundtable_router._all_nodes_delivered( [ {"position_id": "intelligence", "status": "done", "artifact_manifest": []}, { "position_id": "planning", "status": "done", "artifact_manifest": [{"name": "report.md", "path": "mnt/user-data/outputs/report.md"}], }, ] ) @pytest.mark.asyncio async def test_builtin_markdown_fallback_writes_visible_summary_to_thread_outputs(tmp_path, monkeypatch): target = tmp_path / "outputs" / "岗位会商方案总结报告.md" async def resolve(thread_id: str, path: str): assert thread_id == "summary-thread" assert path == "mnt/user-data/outputs/岗位会商方案总结报告.md" return target monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve) artifact = await position_roundtable_router._write_builtin_markdown_fallback( thread_id="summary-thread", kind="summary", content="# 岗位会商方案总结报告\n\n这是对话中生成的完整报告正文。", ) assert target.read_text(encoding="utf-8") == "# 岗位会商方案总结报告\n\n这是对话中生成的完整报告正文。\n" assert artifact["name"] == "岗位会商方案总结报告.md" assert artifact["path"] == "mnt/user-data/outputs/岗位会商方案总结报告.md" assert artifact["mime_type"] == "text/markdown" assert artifact["size_bytes"] == target.stat().st_size @pytest.mark.asyncio async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_skips_write_file(monkeypatch): node = { "id": "node-1", "session_id": "session-a", "node_key": "intent-node", "stage_index": 0, "seat_index": 0, "agent_id": "intent-agent", "position_id": "intelligence", "thread_id": "intent-thread", "status": "done", "latest_answer": "已确认任务意图", "artifact_manifest": [], "revision": 1, "upstream_revision": None, "rejection_count": 0, "last_rejection": None, "invalidated_by": None, } session = { "id": "session-a", "status": "active", "task_snapshot": {}, "chain_id": "chain-a", "chain_snapshot": {}, "intent_thread_id": "intent-thread", "intent_snapshot": {"objective": "形成方案", "constraints": []}, "summary_thread_id": None, "summary_snapshot": None, "action_plan_thread_id": None, "action_plan_snapshot": None, } class StubStore: updates: dict | None = None async def get_session(self, session_id, user_id): assert (session_id, user_id) == ("session-a", "user-a") return session async def list_nodes(self, session_id, user_id): assert (session_id, user_id) == ("session-a", "user-a") return [node] async def update_session(self, session_id, user_id, **updates): assert (session_id, user_id) == ("session-a", "user-a") self.updates = updates return {**session, **updates} store = StubStore() async def write_fallback(**kwargs): assert kwargs["thread_id"] == "summary-thread" assert kwargs["kind"] == "summary" assert kwargs["content"] == "# 方案总结\n\n完整报告正文" return { "name": "岗位会商方案总结报告.md", "path": "mnt/user-data/outputs/岗位会商方案总结报告.md", "mime_type": "text/markdown", "modified_at": "2026-07-23T00:00:00+00:00", "size_bytes": 42, } monkeypatch.setattr(position_roundtable_router, "_write_builtin_markdown_fallback", write_fallback) request = SimpleNamespace( app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=store)), state=SimpleNamespace(user=SimpleNamespace(id="user-a")), ) body = position_roundtable_router.CompleteBuiltinRunRequest( thread_id="summary-thread", content="# 方案总结\n\n完整报告正文", artifact_manifest=[], source_revisions={"intent-node": 1}, ) result = await position_roundtable_router._complete_builtin_run( request, "session-a", kind="summary", body=body, ) assert store.updates is not None snapshot = store.updates["summary_snapshot"] assert snapshot["content"] == "# 方案总结\n\n完整报告正文" assert snapshot["primary_artifact"]["name"] == "岗位会商方案总结报告.md" assert result.summary_snapshot["primary_artifact"]["path"] == ( "mnt/user-data/outputs/岗位会商方案总结报告.md" ) @pytest.mark.asyncio async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store): created = await store.create_session("user-a", external_task_id=None, task_snapshot={}) _, nodes = await store.activate_session( created["id"], "user-a", chain_id="chain-a", chain_snapshot={"id": "chain-a"}, nodes=[ { "node_key": "stage-1-seat-1-agent-a", "stage_index": 0, "seat_index": 0, "agent_id": "agent-a", "position_id": "planning", "status": "ready", } ], ) assert len(nodes) == 1 updated = await store.update_node( created["id"], nodes[0]["node_key"], "user-a", thread_id="langgraph-thread-a", status="done", latest_answer="节点回答", artifact_manifest=[{"name": "产物.md"}], upstream_revision={"intent": 1}, increment_revision=True, ) assert updated is not None assert updated["thread_id"] == "langgraph-thread-a" assert updated["latest_answer"] == "节点回答" assert updated["artifact_manifest"] == [{"name": "产物.md"}] assert updated["revision"] == 1 assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None @pytest.mark.asyncio async def test_reject_node_marks_current_and_invalidates_later_stages(store): created = await store.create_session("user-a", external_task_id=None, task_snapshot={}) _, nodes = await store.activate_session( created["id"], "user-a", chain_id="chain-a", chain_snapshot={"id": "chain-a"}, nodes=[ { "node_key": "stage-1-seat-1-agent-a", "stage_index": 0, "seat_index": 0, "agent_id": "agent-a", "position_id": "planning", "status": "ready", }, { "node_key": "stage-2-seat-1-agent-b", "stage_index": 1, "seat_index": 0, "agent_id": "agent-b", "position_id": "solution", "status": "ready", }, { "node_key": "stage-3-seat-1-agent-c", "stage_index": 2, "seat_index": 0, "agent_id": "agent-c", "position_id": "command", "status": "ready", }, ], ) await store.update_node( created["id"], nodes[0]["node_key"], "user-a", status="done", latest_answer="第一阶段产物", increment_revision=True, ) await store.update_node( created["id"], nodes[1]["node_key"], "user-a", status="done", latest_answer="第二阶段产物", increment_revision=True, ) rejected = await store.reject_node( created["id"], nodes[0]["node_key"], "user-a", title="依据不足", reason="第一阶段判断依据需要补充。", rejected_by="user-a", ) assert rejected is not None _, rejected_nodes = rejected by_key = {node["node_key"]: node for node in rejected_nodes} first = by_key[nodes[0]["node_key"]] second = by_key[nodes[1]["node_key"]] third = by_key[nodes[2]["node_key"]] assert first["status"] == "rejected" assert first["rejection_count"] == 1 assert first["last_rejection"]["title"] == "依据不足" assert first["invalidated_by"] is None assert second["status"] == "stale" assert second["latest_answer"] == "第二阶段产物" assert second["invalidated_by"] == nodes[0]["node_key"] assert third["status"] == "locked" assert third["invalidated_by"] == nodes[0]["node_key"] @pytest.mark.asyncio async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store): created = await store.create_session( "user-a", external_task_id=None, task_snapshot={ "title": "测试任务", "description": "形成协同方案", "direction": "态势研判", }, ) await store.update_session( created["id"], "user-a", intent_snapshot={"objective": "明确处置意图", "constraints": ["时间紧"]}, ) chain = { "id": "chain-a", "seats": [ {"agent_id": "agent-a", "name": "研判智能体", "position_id": "planning"}, {"agent_id": "agent-b", "name": "处置智能体", "position_id": "solution"}, ], } _, nodes = await store.activate_session( created["id"], "user-a", chain_id="chain-a", chain_snapshot=chain, nodes=[ { "node_key": "stage-1-seat-1-agent-a", "stage_index": 0, "seat_index": 0, "agent_id": "agent-a", "position_id": "planning", "status": "ready", }, { "node_key": "stage-2-seat-1-agent-b", "stage_index": 1, "seat_index": 0, "agent_id": "agent-b", "position_id": "solution", "status": "ready", }, ], ) for node in nodes: await store.update_node( created["id"], node["node_key"], "user-a", status="done", latest_answer=f"{node['agent_id']} 产物", increment_revision=True, ) session = await store.get_session(created["id"], "user-a") assert session is not None nodes = await store.list_nodes(created["id"], "user-a") or [] revisions = position_roundtable_router._source_revisions(nodes) summary = position_roundtable_router._build_builtin_snapshot( kind="summary", thread_id="summary-thread", content="# 岗位会商方案总结报告\n\n研判智能体的关键结论。", artifacts=[ { "name": "岗位会商方案总结报告.md", "path": "mnt/user-data/outputs/岗位会商方案总结报告.md", "mime_type": "text/markdown", "modified_at": "2026-07-21T00:00:00+00:00", "size_bytes": 100, } ], source_revisions=revisions, model="test-model", ) assert "岗位会商方案总结报告" in summary["content"] session = await store.update_session( created["id"], "user-a", summary_thread_id="summary-thread", summary_snapshot=summary ) assert session is not None action_plan = position_roundtable_router._build_builtin_snapshot( kind="action_plan", thread_id="action-plan-thread", content="# 岗位会商行动规划\n\n## 子任务拆解", artifacts=[ { "name": "行动规划报告.md", "path": "mnt/user-data/outputs/行动规划报告.md", "mime_type": "text/markdown", "modified_at": "2026-07-21T00:01:00+00:00", "size_bytes": 100, }, { "name": "action-plan-subtasks.json", "path": "mnt/user-data/outputs/action-plan-subtasks.json", "mime_type": "application/json", "modified_at": "2026-07-21T00:01:00+00:00", "size_bytes": 200, }, ], source_revisions=revisions, model="test-model", ) session = await store.update_session( created["id"], "user-a", action_plan_thread_id="action-plan-thread", action_plan_snapshot=action_plan, status="completed", ) assert session is not None assert session["summary_snapshot"]["status"] == "done" assert session["action_plan_snapshot"]["status"] == "done" rejected = await store.reject_node( created["id"], nodes[0]["node_key"], "user-a", title="重做第一阶段", reason="前序依据变化", rejected_by="user-a", ) assert rejected is not None rejected_session, _ = rejected assert rejected_session["status"] == "active" assert rejected_session["summary_snapshot"]["status"] == "stale" assert rejected_session["summary_snapshot"]["invalidated_by"] == nodes[0]["node_key"] assert rejected_session["action_plan_snapshot"]["status"] == "stale" assert rejected_session["action_plan_snapshot"]["invalidated_by"] == nodes[0]["node_key"] def test_rejected_or_stale_node_requires_a_new_markdown_delivery() -> None: old = [ { "name": "report.md", "path": "mnt/user-data/outputs/report.md", "modified_at": "2026-07-21T00:00:00+00:00", "size_bytes": 100, } ] assert not position_roundtable_router._markdown_delivery_changed(old, old) assert position_roundtable_router._markdown_delivery_changed( old, [{**old[0], "modified_at": "2026-07-21T00:05:00+00:00", "size_bytes": 120}], ) def test_activation_requires_a_structured_intent_snapshot() -> None: assert not position_roundtable_router._has_confirmed_intent({"objective": "只有目标"}) assert not position_roundtable_router._has_confirmed_intent({"constraints": []}) assert position_roundtable_router._has_confirmed_intent( {"objective": "明确任务目标", "constraints": []} ) @pytest.mark.asyncio async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch): artifact = tmp_path / "handoff.md" artifact.write_text("上游产物正文:可供下一岗位直接参考。", encoding="utf-8") calls: list[tuple[str, str]] = [] class StubStore: async def get_session(self, session_id, user_id): assert (session_id, user_id) == ("session-a", "user-a") return {"task_snapshot": {"title": "测试任务"}, "intent_snapshot": {"goal": "方案分析"}} async def list_nodes(self, session_id, user_id): assert (session_id, user_id) == ("session-a", "user-a") return [ { "node_key": "stage-1-seat-1-agent-a", "stage_index": 0, "seat_index": 0, "agent_id": "agent-a", "position_id": "planning", "thread_id": "upstream-thread", "status": "done", "revision": 1, "latest_answer": "上游问答结果", "artifact_manifest": [ { "name": "handoff.md", "path": "mnt/user-data/outputs/handoff.md", "mime_type": "text/markdown", }, { "name": "outside.md", "path": "mnt/user-data/uploads/outside.md", "mime_type": "text/markdown", }, ], }, { "node_key": "stage-2-seat-1-agent-b", "stage_index": 1, "seat_index": 0, "agent_id": "agent-b", "position_id": "solution", "thread_id": "current-thread", "status": "ready", "revision": 0, "latest_answer": None, "artifact_manifest": [], }, { "node_key": "stage-1-seat-2-agent-stale", "stage_index": 0, "seat_index": 1, "agent_id": "agent-stale", "position_id": "planning", "thread_id": "stale-thread", "status": "stale", "revision": 1, "latest_answer": "失效上游产物", "artifact_manifest": [], }, ] async def resolve(thread_id: str, path: str): calls.append((thread_id, path)) assert thread_id == "upstream-thread" assert path == "mnt/user-data/outputs/handoff.md" return artifact monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve) request = SimpleNamespace( app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=StubStore())), state=SimpleNamespace(user=SimpleNamespace(id="user-a")), ) context = await position_roundtable_router._handoff_context( request, "session-a", "stage-2-seat-1-agent-b" ) upstream = context["upstream_nodes"][0] assert len(context["upstream_nodes"]) == 1 assert upstream["latest_answer"] == "上游问答结果" assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。" assert "content" not in upstream["artifacts"][1] assert calls == [("upstream-thread", "mnt/user-data/outputs/handoff.md")] @pytest.mark.asyncio async def test_archived_session_rejects_node_writes_and_keeps_history_readable(): class ArchivedStore: async def get_session(self, session_id, user_id): assert (session_id, user_id) == ("session-a", "user-a") return {"id": session_id, "status": "archived"} async def get_node(self, session_id, node_key, user_id): return { "node_key": node_key, "status": "ready", "thread_id": None, } request = SimpleNamespace( app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=ArchivedStore())), state=SimpleNamespace(user=SimpleNamespace(id="user-a")), ) with pytest.raises(HTTPException) as error: await position_roundtable_router.bind_node_thread( request, "session-a", "stage-1-seat-1-agent-a", position_roundtable_router.NodeThreadRequest(thread_id="thread-a"), ) assert error.value.status_code == 409 assert "已归档" in str(error.value.detail) @pytest.mark.asyncio async def test_locked_node_cannot_bind_a_thread_before_its_stage_is_ready(): class LockedStore: async def get_session(self, session_id, user_id): return {"id": session_id, "status": "active"} async def get_node(self, session_id, node_key, user_id): return { "node_key": node_key, "status": "locked", "thread_id": None, } request = SimpleNamespace( app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=LockedStore())), state=SimpleNamespace(user=SimpleNamespace(id="user-a")), ) with pytest.raises(HTTPException) as error: await position_roundtable_router.bind_node_thread( request, "session-a", "stage-2-seat-1-agent-b", position_roundtable_router.NodeThreadRequest(thread_id="thread-b"), ) assert error.value.status_code == 409 assert "未解锁" in str(error.value.detail) class _ThreadMetaSessionProbe: def __init__(self, events: list[str]) -> None: self._events = events async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb) -> None: return None def add(self, row) -> None: self._events.append(f"add:{row.thread_id}") async def commit(self) -> None: self._events.append("commit") async def refresh(self, row) -> None: self._events.append(f"refresh:{row.thread_id}") @pytest.mark.asyncio async def test_thread_meta_create_never_reads_back_after_commit(): """No caller may depend on a post-commit re-SELECT of the new row. On MySQL behind a read/write-splitting endpoint that read can land on a lagging replica, come back empty, and raise "Could not refresh instance". """ events: list[str] = [] repo = ThreadMetaRepository(lambda: _ThreadMetaSessionProbe(events)) # type: ignore[arg-type] direct = await repo.create( "roundtable-thread", user_id=None, metadata={"thread_type": "roundtable"}, ) ordinary = await repo.create("ordinary-thread", user_id=None, display_name="对话") assert events == [ "add:roundtable-thread", "commit", "add:ordinary-thread", "commit", ] # The returned dict is assembled from the in-memory row, so it must still be # complete for every column -- including ones no caller passed in. assert direct["status"] == "idle" assert direct["metadata"] == {"thread_type": "roundtable"} assert direct["created_at"] and direct["updated_at"] assert ordinary["status"] == "idle" assert ordinary["display_name"] == "对话" class _ThreadStoreProbe: def __init__(self) -> None: self.created: list[tuple[str, dict[str, Any]]] = [] self.deleted: list[str] = [] async def create(self, thread_id: str, **kwargs) -> dict[str, Any]: self.created.append((thread_id, kwargs)) return {"thread_id": thread_id} async def delete(self, thread_id: str) -> None: self.deleted.append(thread_id) class _FailingCheckpointer: def __init__(self, exc: BaseException) -> None: self._exc = exc self.deleted: list[str] = [] async def aput(self, *args, **kwargs): raise self._exc async def adelete_thread(self, thread_id: str) -> None: self.deleted.append(thread_id) @pytest.mark.asyncio async def test_direct_thread_creation_cleans_failed_checkpoint(monkeypatch): store = _ThreadStoreProbe() checkpointer = _FailingCheckpointer(RuntimeError("checkpoint unavailable")) monkeypatch.setattr(gateway_deps, "get_thread_store", lambda request: store) monkeypatch.setattr(gateway_deps, "get_checkpointer", lambda request: checkpointer) with pytest.raises(HTTPException) as exc_info: await multi_agent._create_thread_direct( SimpleNamespace(), metadata={"thread_type": "roundtable"}, ) assert exc_info.value.status_code == 500 assert len(store.created) == 1 thread_id, create_kwargs = store.created[0] assert create_kwargs["metadata"] == {"thread_type": "roundtable"} assert store.deleted == [thread_id] assert checkpointer.deleted == [thread_id] @pytest.mark.asyncio async def test_direct_thread_creation_cleans_metadata_when_cancelled(monkeypatch): store = _ThreadStoreProbe() checkpointer = _FailingCheckpointer(asyncio.CancelledError()) monkeypatch.setattr(gateway_deps, "get_thread_store", lambda request: store) monkeypatch.setattr(gateway_deps, "get_checkpointer", lambda request: checkpointer) with pytest.raises(asyncio.CancelledError): await multi_agent._create_thread_direct( SimpleNamespace(), metadata={"thread_type": "roundtable"}, ) thread_id, _ = store.created[0] assert store.deleted == [thread_id] assert checkpointer.deleted == [thread_id]