Merge remote-tracking branch 'origin/master' into ji
# Conflicts: # .gitignore # frontend-web/src/components/workspace/input-box.tsx # frontend-web/src/core/threads/types.ts # frontend-web/src/pages/ChatPage.tsx
This commit is contained in:
commit
c4fd9dd7da
233 files changed
+12524
-1163
No files matched your search
@@ -0,0 +1,151 @@
|
||||
"""Focused regression tests for administrator model configuration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from starlette.routing import Match
|
||||
|
||||
from app.gateway.routers import model_management as router
|
||||
|
||||
|
||||
def test_replace_models_block_keeps_other_sections_and_comments() -> None:
|
||||
raw = """config_version: 1
|
||||
models:
|
||||
# Existing provider note
|
||||
- name: old-model
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: old-model
|
||||
# Disabled provider example remains useful to operators.
|
||||
# - name: disabled-model
|
||||
# model: disabled-model
|
||||
agents:
|
||||
default: lead_agent
|
||||
"""
|
||||
|
||||
replaced = router._replace_models_block(
|
||||
raw,
|
||||
[
|
||||
{
|
||||
"name": "new-model",
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "new-model",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
parsed = yaml.safe_load(replaced)
|
||||
assert parsed["models"] == [
|
||||
{
|
||||
"name": "new-model",
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "new-model",
|
||||
}
|
||||
]
|
||||
assert parsed["agents"] == {"default": "lead_agent"}
|
||||
assert "# Existing provider note" in replaced
|
||||
assert "# Disabled provider example remains useful to operators." in replaced
|
||||
|
||||
|
||||
def test_connection_error_redaction_hides_literal_and_resolved_keys() -> None:
|
||||
message = "provider rejected raw-secret and resolved-secret"
|
||||
assert router._redact_text(message, ["raw-secret", "resolved-secret"]) == "provider rejected *** and ***"
|
||||
|
||||
|
||||
def test_model_mutation_routes_accept_names_with_slashes() -> None:
|
||||
for method in ("PUT", "DELETE"):
|
||||
route = next(
|
||||
item
|
||||
for item in router.router.routes
|
||||
if getattr(item, "methods", set()) == {method} and getattr(item, "path", "") == "/api/admin/models/{model_name:path}"
|
||||
)
|
||||
match, child_scope = route.matches(
|
||||
{
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": "/api/admin/models/zai-org/GLM-5-FP8",
|
||||
}
|
||||
)
|
||||
assert match is Match.FULL
|
||||
assert child_scope["path_params"] == {"model_name": "zai-org/GLM-5-FP8"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_configuration_removes_matching_model_and_activates_config(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
"""models:
|
||||
- name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: GLM-5-FP8
|
||||
- name: keep-me
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: keep-me
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
saved: dict[str, object] = {}
|
||||
|
||||
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
saved["raw_before"] = raw_before
|
||||
saved["models"] = models
|
||||
return models
|
||||
|
||||
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
|
||||
monkeypatch.setattr(router, "_save_models", save_models)
|
||||
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
|
||||
|
||||
await router.delete_model_configuration("zai-org/GLM-5-FP8", request)
|
||||
|
||||
assert "zai-org/GLM-5-FP8" in str(saved["raw_before"])
|
||||
assert saved["models"] == [
|
||||
{
|
||||
"name": "keep-me",
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "keep-me",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_test_uses_one_minimal_prompt_without_writing_config(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.yaml"
|
||||
original = "models: []\nagent: lead_agent\n"
|
||||
config_path.write_text(original, encoding="utf-8")
|
||||
messages_seen = []
|
||||
|
||||
class FakeChatModel:
|
||||
async def ainvoke(self, messages):
|
||||
messages_seen.extend(messages)
|
||||
return SimpleNamespace(content="OK")
|
||||
|
||||
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
|
||||
monkeypatch.setattr(router, "create_chat_model", lambda *_args, **_kwargs: FakeChatModel())
|
||||
request = SimpleNamespace(
|
||||
state=SimpleNamespace(user=SimpleNamespace(system_role="admin")),
|
||||
app=SimpleNamespace(state=SimpleNamespace(config=SimpleNamespace(model_copy=lambda **_kwargs: SimpleNamespace()))),
|
||||
)
|
||||
body = router.ModelTestRequest(
|
||||
model=router.ModelUpsertRequest(
|
||||
name="test-model",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
model="test-model",
|
||||
api_key="raw-secret",
|
||||
)
|
||||
)
|
||||
|
||||
result = await router.test_model_connection(body, request)
|
||||
|
||||
assert result.success is True
|
||||
assert result.preview == "OK"
|
||||
assert len(messages_seen) == 1
|
||||
assert messages_seen[0].content == "Reply with exactly: OK"
|
||||
assert config_path.read_text(encoding="utf-8") == original
|
||||
@@ -3,6 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -14,9 +17,14 @@ 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
|
||||
@@ -55,6 +63,248 @@ def test_position_roundtable_router_has_intranet_compatibility_prefix():
|
||||
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_position_intent_clarification_waits_for_the_user_before_ready():
|
||||
"""A same-run READY block must not consume the user-assistance pause."""
|
||||
assert intent_router._clarification_blocks_ready(
|
||||
intent_router.POSITION_INTENT_AGENT_ID,
|
||||
{"name": "ask_clarification"},
|
||||
is_fresh=True,
|
||||
)
|
||||
assert not 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."""
|
||||
@@ -91,6 +341,71 @@ async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch)
|
||||
}
|
||||
|
||||
|
||||
@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(
|
||||
@@ -208,14 +523,91 @@ async def test_conversation_snapshots_round_trip_and_history_delete_removes_node
|
||||
assert saved_node is not None
|
||||
assert saved_node["conversation_snapshot"] == node_conversation
|
||||
|
||||
# Ownership checks apply to delete as well.
|
||||
assert not await store.delete_session(created["id"], "user-b")
|
||||
assert await store.get_session(created["id"], "user-a") is not None
|
||||
assert await store.delete_session(created["id"], "user-a")
|
||||
# Position roundtable history is shared by taskId, so a collaborator can
|
||||
# read and remove the same task conversation without being its creator.
|
||||
assert await store.get_session(created["id"], "user-b") is not None
|
||||
assert await store.delete_session(created["id"], "user-b")
|
||||
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_shared_but_never_mixes_tasks(store):
|
||||
shared = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id="external-task-100",
|
||||
task_snapshot={"id": "external-task-100", "title": "共享任务"},
|
||||
)
|
||||
await store.create_session(
|
||||
"user-b",
|
||||
external_task_id="external-task-200",
|
||||
task_snapshot={"id": "external-task-200", "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] == [shared["id"]]
|
||||
|
||||
updated = await store.update_session(
|
||||
shared["id"],
|
||||
"user-b",
|
||||
task_snapshot={"id": "external-task-100", "title": "协作者已更新"},
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated["task_snapshot"]["title"] == "协作者已更新"
|
||||
assert (await store.get_session(shared["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 shared 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 = {
|
||||
@@ -490,6 +882,7 @@ async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_
|
||||
}
|
||||
session = {
|
||||
"id": "session-a",
|
||||
"external_task_id": "task-fallback",
|
||||
"status": "active",
|
||||
"task_snapshot": {},
|
||||
"chain_id": "chain-a",
|
||||
@@ -561,7 +954,7 @@ async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
|
||||
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"],
|
||||
@@ -602,7 +995,9 @@ async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(stor
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_node_marks_current_and_invalidates_later_stages(store):
|
||||
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
|
||||
created = await store.create_session(
|
||||
"user-a", external_task_id="task-reject", task_snapshot={}
|
||||
)
|
||||
_, nodes = await store.activate_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
@@ -682,7 +1077,7 @@ async def test_reject_node_marks_current_and_invalidates_later_stages(store):
|
||||
async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
|
||||
created = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id=None,
|
||||
external_task_id="task-builtin-invalidation",
|
||||
task_snapshot={
|
||||
"title": "测试任务",
|
||||
"description": "形成协同方案",
|
||||
|
||||
@@ -116,3 +116,13 @@ async def test_unknown_mode_value_ignored_falls_through(_gw):
|
||||
await _run(gateway)
|
||||
assert captured["thinking_enabled"] is True
|
||||
assert captured["reasoning_effort"] == "low"
|
||||
|
||||
|
||||
def test_chain_seat_schema_keeps_human_validation_checkpoint():
|
||||
"""Chain-seat JSON must preserve the frontend Step 2 pause configuration."""
|
||||
from app.gateway.routers.roundtable_chains import ChainSeatSchema
|
||||
|
||||
assert ChainSeatSchema(agent_id="seat-1").human_validation is False
|
||||
assert ChainSeatSchema(agent_id="seat-1", human_validation=True).model_dump()[
|
||||
"human_validation"
|
||||
] is True
|
||||
Reference in new issue
Block a user