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:
吉鑫升 committed 2026-08-12 15:55:51 +08:00
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