席位线程隔离导致下游 read_file 找不到上游路径;镜像到 workspace/upstream 并改写 handoff 引用,附带安全路径校验与 SQL 回退测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
1616 lines
60 KiB
Python
1616 lines
60 KiB
Python
"""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]
|