Files
deerflow-code/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py
T
董宏凯andCursor 32e509b2b3 fix: 岗位会商 handoff 将上游产物镜像到当前席位沙箱
席位线程隔离导致下游 read_file 找不到上游路径;镜像到 workspace/upstream 并改写 handoff 引用,附带安全路径校验与 SQL 回退测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 18:35:17 +08:00

1616 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Persistence coverage for the standalone position-collaboration workflow."""
from __future__ import annotations
import asyncio
import importlib.util
import json
import zipfile
from pathlib import Path
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 _roundtable_seed as roundtable_seed
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 app.gateway.routers._roundtable_seed import (
POSITION_INTENT_AGENT_ID,
required_agent_ids,
)
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)
try:
yield PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
finally:
await engine.dispose()
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"
def test_position_intent_agent_is_a_seeded_functional_agent():
"""Fresh deployments must materialize the dedicated position intent agent."""
assert POSITION_INTENT_AGENT_ID in set(required_agent_ids())
def test_action_plan_seed_binds_the_batch_create_skill_and_nine_field_contract():
"""New deployments must not fall back to the retired subtask JSON contract."""
action_assets = roundtable_seed._ASSETS_DIR / roundtable_seed.ACTION_PLAN_AGENT_ID
soul = (action_assets / "SOUL.md").read_text(encoding="utf-8")
config = (action_assets / "config.yaml").read_text(encoding="utf-8")
assert "position-roundtable-action-create" in config
for field in (
"actionName",
"actionDescription",
"actionType",
"publishChannel",
"infoFormat",
"responsiblePosition",
"startTime",
"endTime",
"taskId",
):
assert field in soul
assert "/task/action/add" in soul
assert "批量新增" in soul
@pytest.mark.asyncio
async def test_action_plan_prompt_receives_frontend_task_id_and_dictionary_values():
"""The action planner must see exact dictValue choices, not frontend labels only."""
dictionaries = position_roundtable_router.ActionPlanDictionaries(
publish_channels=[{"label": "微信公众号", "value": "11"}],
action_types=[{"label": "宣传", "value": "21"}],
info_formats=[{"label": "图文", "value": "31"}],
responsible_positions=[{"label": "规划岗", "value": "41"}],
)
prompt = await position_roundtable_router._build_builtin_prompt(
kind="action_plan",
session={
"task_snapshot": {"title": "任务 A", "description": "生成行动"},
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
},
nodes=[],
action_task_id=1001,
action_dictionaries=dictionaries,
)
assert "当前 taskId:1001" in prompt
assert "微信公众号 → 11" in prompt
assert "宣传 → 21" in prompt
assert "图文 → 31" in prompt
assert "规划岗 → 41" in prompt
assert "id、title、goal" in prompt
@pytest.mark.asyncio
async def test_action_plan_prompt_degrades_when_task_dictionaries_are_unavailable():
"""A 404 from the task-system dictionaries must not block local planning."""
prompt = await position_roundtable_router._build_builtin_prompt(
kind="action_plan",
session={
"task_snapshot": {"title": "本地演示任务"},
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
},
nodes=[],
action_task_id="abc123",
action_dictionaries=position_roundtable_router.ActionPlanDictionaries(),
)
assert "字典未获取完整" in prompt
assert "不得调用 Skill" in prompt
assert "0 占位" in prompt
def test_action_plan_prepare_keeps_a_local_mock_task_id_without_numeric_coercion():
"""A local demo id must not stop the action-plan conversation at prepare time."""
body = position_roundtable_router.PrepareBuiltinRunRequest(task_id="abc123")
assert body.task_id == "abc123"
def test_action_batch_skill_accepts_only_the_nine_field_array(tmp_path):
"""The batch Skill must reject old task-subdivision fields before any HTTP call."""
archive_path = roundtable_seed._ASSETS_DIR.parents[3] / "skill-packages" / "position-roundtable-action-create.skill"
with zipfile.ZipFile(archive_path) as archive:
archive.extractall(tmp_path / "skill-package")
script_path = tmp_path / "skill-package" / "position-roundtable-action-create" / "scripts" / "batch_add_actions.py"
spec = importlib.util.spec_from_file_location("position_action_batch_skill", script_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
valid = [{
"actionName": "发布内容",
"actionDescription": "向目标渠道发布审核通过的信息。",
"actionType": "1",
"publishChannel": "2",
"infoFormat": "3",
"responsiblePosition": "4",
"startTime": "2026-08-12T09:00:00+08:00",
"endTime": "2026-08-12T18:00:00+08:00",
"taskId": 1001,
}]
source = tmp_path / "action-plan-subtasks.json"
source.write_text(json.dumps(valid, ensure_ascii=False), encoding="utf-8")
assert module.read_actions(source, 1001) == [{
**valid[0],
"actionType": 1,
"publishChannel": 2,
"infoFormat": 3,
"responsiblePosition": 4,
}]
source.write_text(json.dumps([{**valid[0], "id": "legacy"}], ensure_ascii=False), encoding="utf-8")
with pytest.raises(module.ActionInputError, match="多余字段"):
module.read_actions(source, 1001)
def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
"""Dedicated schema must not regress into solutioning or gatekeeping."""
prompt = (
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
).read_text(encoding="utf-8")
for phrase in (
"最多 5 轮用户协助",
"除 ask_clarification 外,禁止调用任何工具、技能或操作",
"你都不拒绝、不评价、不设限",
"不得说“不能帮你”“不在职责范围内”“请换一个问题”",
"coreGoals / riskWarnings / keyPoints / strategicSignificance",
"不能把“主题已识别”误判为“意图已明确”",
"任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助",
"必须分析任务级风险",
"同一轮可发起多张用户协助卡",
"不得把多个问题塞进一张卡",
):
assert phrase in prompt
def test_fresh_intent_clarification_waits_for_the_user_before_ready():
"""A same-run READY block must not consume a fresh assistance pause."""
assert intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
)
assert intent_router._clarification_blocks_ready(
intent_router.INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
None,
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=False,
)
def test_position_intent_counts_multi_card_assistance_as_one_round():
"""Five user-assistance rounds must not be consumed by five cards at once."""
messages: list[dict[str, Any]] = [
{
"type": "ai",
"tool_calls": [
{"id": "scope", "name": "ask_clarification", "args": {}},
{"id": "purpose", "name": "ask_clarification", "args": {}},
{"id": "time", "name": "ask_clarification", "args": {}},
],
},
{"type": "human", "content": "用户统一答复"},
{
"type": "ai",
"tool_calls": [
{"id": "risk", "name": "ask_clarification", "args": {}},
],
},
]
assert intent_router._count_past_clarifications(messages) == 4
assert intent_router._count_past_clarification_rounds(messages) == 2
assert intent_router._max_clarification_rounds(intent_router.INTENT_AGENT_ID) == 3
assert (
intent_router._max_clarification_rounds(intent_router.POSITION_INTENT_AGENT_ID)
== 5
)
def test_position_intent_seed_repairs_its_contract_without_touching_other_agents(tmp_path, monkeypatch):
"""The dedicated schema prompt must replace an accidentally seeded old version."""
agents_dir = tmp_path / "agents"
monkeypatch.setattr(
roundtable_seed,
"get_paths",
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
)
position_dir = agents_dir / POSITION_INTENT_AGENT_ID
position_dir.mkdir(parents=True)
(position_dir / "SOUL.md").write_text("outdated prompt", encoding="utf-8")
assert roundtable_seed._ensure_one(POSITION_INTENT_AGENT_ID) is True
expected_soul = (
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
).read_text(encoding="utf-8")
assert (position_dir / "SOUL.md").read_text(encoding="utf-8") == expected_soul
generic_dir = agents_dir / roundtable_seed.INTENT_AGENT_ID
generic_dir.mkdir(parents=True)
generic_soul = generic_dir / "SOUL.md"
generic_soul.write_text("deployed generic tune", encoding="utf-8")
assert roundtable_seed._ensure_one(roundtable_seed.INTENT_AGENT_ID) is False
assert generic_soul.read_text(encoding="utf-8") == "deployed generic tune"
def test_existing_action_planner_only_merges_the_owned_skill(tmp_path, monkeypatch):
"""A code upgrade may add the Skill without overwriting a user-tuned SOUL."""
agents_dir = tmp_path / "agents"
monkeypatch.setattr(
roundtable_seed,
"get_paths",
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
)
action_dir = agents_dir / roundtable_seed.ACTION_PLAN_AGENT_ID
action_dir.mkdir(parents=True)
(action_dir / "SOUL.md").write_text("deployed action-plan tune", encoding="utf-8")
(action_dir / "config.yaml").write_text(
"id: position-action-planner\nskills:\n - existing-skill\n",
encoding="utf-8",
)
assert roundtable_seed._ensure_one(roundtable_seed.ACTION_PLAN_AGENT_ID) is True
config = (action_dir / "config.yaml").read_text(encoding="utf-8")
assert "existing-skill" in config
assert "position-roundtable-action-create" in config
assert (action_dir / "SOUL.md").read_text(encoding="utf-8") == "deployed action-plan tune"
@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_position_intent_init_selects_its_dedicated_agent(monkeypatch):
"""Position roundtable must never mutate the generic Step-1 agent contract."""
captured: dict[str, object] = {}
monkeypatch.setattr(intent_router, "ensure_roundtable_functional_agents", lambda: None)
async def fake_create_thread_direct(request, *, metadata):
captured["metadata"] = metadata
return "position-intent-thread"
monkeypatch.setattr(intent_router, "_create_thread_direct", fake_create_thread_direct)
result = await intent_router.init_intent(
SimpleNamespace(),
{
"intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
"task_context": {"task": {"id": "task-position-001"}},
},
)
assert result == {
"status": "success",
"agent_id": intent_router.POSITION_INTENT_AGENT_ID,
"thread_id": "position-intent-thread",
}
assert captured["metadata"] == {
**intent_router._ROUNDTABLE_THREAD_METADATA,
"taskId": "task-position-001",
"position_roundtable_task_id": "task-position-001",
"position_task_id": "task-position-001:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
"position_roundtable_intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
}
def test_position_intent_schema_is_required_for_new_snapshots_and_legacy_is_kept():
assert position_roundtable_router._has_confirmed_intent(
{
"coreGoals": ["形成统一处置方案"],
"riskWarnings": [],
"keyPoints": ["需要在本周完成"],
"strategicSignificance": ["为后续岗位协同提供统一边界"],
}
)
assert not position_roundtable_router._has_confirmed_intent(
{"coreGoals": ["形成统一处置方案"], "riskWarnings": []}
)
assert position_roundtable_router._has_confirmed_intent(
{"objective": "历史任务", "constraints": []}
)
answer = position_roundtable_router._intent_node_answer(
{
"coreGoals": ["形成统一处置方案"],
"riskWarnings": ["关键数据尚待确认"],
"keyPoints": ["聚焦当前任务范围"],
"strategicSignificance": ["支撑业务链协同推进"],
}
)
assert "核心目标" in answer
assert "风险提示" in answer
assert "关键要点" in answer
assert "战略意义" in answer
@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
# History is partitioned by creator: another user working the same taskId
# can neither read nor remove this conversation.
assert await store.get_session(created["id"], "user-b") is None
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_task_scoped_history_is_partitioned_by_user_but_never_mixes_tasks(store):
creator_session = await store.create_session(
"user-a",
external_task_id="external-task-100",
task_snapshot={"id": "external-task-100", "title": "共享任务"},
)
visible_to_creator = await store.list_sessions(
"user-a", external_task_id="external-task-100"
)
assert [row["id"] for row in visible_to_creator] == [creator_session["id"]]
# The same taskId no longer exposes another user's rows: a collaborator
# starts from an empty list and only ever sees rows they created.
assert await store.list_sessions("user-b", external_task_id="external-task-100") == []
collaborator_session = await store.create_session(
"user-b",
external_task_id="external-task-100",
task_snapshot={"id": "external-task-100", "title": "协作者自己的会话"},
)
visible_to_collaborator = await store.list_sessions(
"user-b", external_task_id="external-task-100"
)
assert [row["id"] for row in visible_to_collaborator] == [collaborator_session["id"]]
assert await store.update_session(
creator_session["id"],
"user-b",
task_snapshot={"id": "external-task-100", "title": "协作者已更新"},
) is None
assert (await store.get_session(creator_session["id"], "user-a"))["task_snapshot"]["title"] == "共享任务"
@pytest.mark.asyncio
async def test_unscoped_history_keeps_original_user_isolation(store):
personal = await store.create_session(
"user-a",
external_task_id=None,
task_snapshot={"id": "legacy-card-id", "title": "个人会话"},
)
assert await store.get_session(personal["id"], "user-b") is None
assert await store.list_sessions("user-b") == []
assert await store.update_session(personal["id"], "user-b", status="active") is None
assert not await store.delete_session(personal["id"], "user-b")
# Old client records could already carry a mock task id. The new database
# marker keeps them personal after the migration; only a new explicit
# route taskId creates a task-bound row.
legacy_with_mock_task_id = await store.create_session(
"user-a",
external_task_id="legacy-mock-task-id",
task_scoped=False,
task_snapshot={"id": "legacy-card-id", "title": "鏃х増涓汉浼氳瘽"},
)
assert await store.get_session(legacy_with_mock_task_id["id"], "user-b") is None
assert await store.list_sessions("user-b", external_task_id="legacy-mock-task-id") == []
def test_session_create_allows_missing_task_id_and_scoped_snapshots_keep_scope():
body = position_roundtable_router.SessionCreateRequest(
external_task_id=" task-route-001 ",
task_snapshot={"id": "wrong-id", "title": "任务"},
)
assert body.external_task_id == "task-route-001"
assert position_roundtable_router._task_snapshot_for_scope(
body.external_task_id,
body.task_snapshot,
) == {
"id": "task-route-001",
"externalTaskId": "task-route-001",
"title": "任务",
}
assert position_roundtable_router.SessionCreateRequest(task_snapshot={}).external_task_id is None
assert position_roundtable_router.SessionCreateRequest(external_task_id=" ").external_task_id is None
assert position_roundtable_router._task_snapshot_for_scope(
"legacy-mock-task-id",
{"id": "legacy-card-id", "title": "旧记录"},
task_scoped=False,
) == {"id": "legacy-card-id", "title": "旧记录"}
@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",
"external_task_id": "task-fallback",
"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_without_task_id(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="task-reject", 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="task-builtin-invalidation",
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:
from deerflow.persistence.position_roundtable.delivery import markdown_delivery_changed
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 markdown_delivery_changed(old, old)
assert 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):
upstream_dir = tmp_path / "up" / "outputs"
upstream_dir.mkdir(parents=True)
artifact = upstream_dir / "handoff.md"
artifact.write_text("上游产物正文:可供下一岗位直接参考。", encoding="utf-8")
mirror_root = tmp_path / "current" / "workspace" / "upstream"
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))
if thread_id == "upstream-thread":
assert path == "mnt/user-data/outputs/handoff.md"
return artifact
assert thread_id == "current-thread"
assert path == "mnt/user-data/workspace/upstream/handoff.md"
return mirror_root / "handoff.md"
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 upstream["artifacts"][0]["upstream_path"] == "mnt/user-data/outputs/handoff.md"
assert upstream["artifacts"][0]["path"] == "mnt/user-data/workspace/upstream/handoff.md"
assert upstream["artifacts"][0]["mirrored_to_sandbox"] is True
assert "content" not in upstream["artifacts"][1]
assert "mirrored_to_sandbox" not in upstream["artifacts"][1]
assert upstream["artifacts"][1]["path"] == "mnt/user-data/uploads/outside.md"
assert (mirror_root / "handoff.md").read_text(encoding="utf-8") == "上游产物正文:可供下一岗位直接参考。"
assert "/mnt/user-data/workspace/upstream/handoff.md" in context["instructions"]
assert set(calls) == {
("upstream-thread", "mnt/user-data/outputs/handoff.md"),
("current-thread", "mnt/user-data/workspace/upstream/handoff.md"),
}
@pytest.mark.asyncio
async def test_handoff_mirror_falls_back_to_persisted_content_and_skips_unsafe_paths(
tmp_path, monkeypatch
):
mirror_root = tmp_path / "mirror"
resolved: 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": {}}
async def list_nodes(self, session_id, user_id):
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": "gone.md",
"path": "mnt/user-data/outputs/gone.md",
"mime_type": "text/markdown",
"content": "数据库恢复正文",
},
{
"name": "escape.md",
"path": "mnt/user-data/outputs/../escape.md",
"mime_type": "text/markdown",
"content": "不应被镜像",
},
],
},
{
"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": [],
},
]
missing_source = tmp_path / "missing-source"
async def resolve(thread_id: str, path: str):
resolved.append((thread_id, path))
if thread_id == "upstream-thread":
return missing_source / "not-materialized.md"
assert thread_id == "current-thread"
return mirror_root / Path(path).name
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"
)
artifacts = context["upstream_nodes"][0]["artifacts"]
assert artifacts[0]["path"] == "mnt/user-data/workspace/upstream/gone.md"
assert artifacts[0]["mirrored_to_sandbox"] is True
assert (mirror_root / "gone.md").read_text(encoding="utf-8") == "数据库恢复正文"
# 持久化正文同时内嵌,沙箱磁盘丢失也不影响下一岗位读到内容。
assert artifacts[0]["content"] == "数据库恢复正文"
assert artifacts[1]["path"] == "mnt/user-data/outputs/../escape.md"
assert "mirrored_to_sandbox" not in artifacts[1]
assert not (mirror_root / "escape.md").exists()
assert all(".." not in path for _, path in resolved)
@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,
}
async def apply_node_command(
self, session_id, node_key, user_id, *, command_id, command_type, payload=None,
expected_session_version=None,
):
# Mirrors the in-lock rejection a locked node produces in the real store.
return {
"ok": False,
"http_status": 409,
"detail": "上游节点尚未完成,当前节点未解锁",
}
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]