feat: 完善策略优选项目工作流

This commit is contained in:
Administrator committed 2026-09-09 20:49:59 +08:00
1 parent a26fd32eba
commit 5d6627af50
45 files changed
+7235 -768

No files matched your search

@@ -26,6 +26,7 @@ from app.gateway.roundtable_model_fallback import (
is_llm_error_message,
reason_text,
)
from app.gateway.routers._strategy_selection_seed import default_strategy_agent_roles
from deerflow.agents.middlewares.llm_error_handling_middleware import (
LLM_ERROR_MARKER,
LLMErrorHandlingMiddleware,
@@ -143,9 +144,7 @@ def test_parallel_agent_run_allows_empty_content_clarification(monkeypatch) -> N
}
async def fake_stream_upstream(*_args, **_kwargs):
yield None, [
f"data: {json.dumps({'messages': [clarification_message]}, ensure_ascii=False)}"
]
yield None, [f"data: {json.dumps({'messages': [clarification_message]}, ensure_ascii=False)}"]
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
@@ -173,14 +172,193 @@ def test_parallel_agent_run_allows_empty_content_clarification(monkeypatch) -> N
assert event["clarification"]["question"] == "请确认策略时间范围。"
def test_parallel_agent_final_values_keep_typed_reasoning_out_of_content(monkeypatch) -> None:
final_message = {
"type": "ai",
"content": [
{"type": "thinking", "text": "核对约束与证据"},
{"type": "text", "text": "这是最终可见策略正文,包含目标、路径和验收标准。"},
],
}
async def fake_stream_upstream(*_args, **_kwargs):
yield None, [f"data: {json.dumps({'messages': [final_message]}, ensure_ascii=False)}"]
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
async def collect() -> list[object]:
return [
item
async for item in parallel_agents._run_one_agent(
None, # type: ignore[arg-type]
"http://gateway.test",
{"Authorization": "Bearer test"},
"strategy-selection-angle-security",
"thread-id",
"test task",
"",
)
]
events = asyncio.run(collect())
assert len(events) == 1
event = events[0]
assert isinstance(event, dict)
assert event["content"] == "这是最终可见策略正文,包含目标、路径和验收标准。"
assert event["reasoning"] == "核对约束与证据"
def test_parallel_agent_final_values_recovers_template_injected_reasoning(monkeypatch) -> None:
final_message = {
"type": "ai",
"content": "模板注入的内部推理</think>这是最终可见策略正文,包含目标、路径和验收标准。",
}
async def fake_stream_upstream(*_args, **_kwargs):
yield None, [f"data: {json.dumps({'messages': [final_message]}, ensure_ascii=False)}"]
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
async def collect() -> list[object]:
return [
item
async for item in parallel_agents._run_one_agent(
None, # type: ignore[arg-type]
"http://gateway.test",
{"Authorization": "Bearer test"},
"strategy-selection-angle-security",
"thread-id",
"test task",
"",
)
]
events = asyncio.run(collect())
event = events[0]
assert isinstance(event, dict)
assert event["content"] == "这是最终可见策略正文,包含目标、路径和验收标准。"
assert event["reasoning"] == "模板注入的内部推理"
def test_parallel_agent_substantive_content_accepts_chinese_and_english() -> None:
assert parallel_agents._has_substantive_content("这是一份包含目标、前置条件、阶段路径和验收标准的完整策略。")
assert parallel_agents._has_substantive_content(
"A complete strategy with objectives, prerequisites, milestones, and measurable outcomes."
)
assert parallel_agents._has_substantive_content("A complete strategy with objectives, prerequisites, milestones, and measurable outcomes.")
assert not parallel_agents._has_substantive_content("<think>internal reasoning only</think>!!!")
def test_parallel_agent_extracts_provider_reasoning_without_mixing_answer() -> None:
answer, reasoning = parallel_agents._extract_ai_stream_parts(
{
"type": "AIMessageChunk",
"content": [
{"type": "thinking_delta", "text": "先核对边界"},
{"type": "text", "text": "可见正文"},
],
"additional_kwargs": {"reasoning_content": ",再比较路径"},
}
)
assert answer == "可见正文"
assert reasoning == "先核对边界,再比较路径"
answer, reasoning = parallel_agents._extract_ai_stream_parts(
{
"type": "AIMessageChunk",
"content": [
{"type": "thinking", "thinking": "结构化思考字段"},
{"type": "text", "text": "最终正文"},
],
}
)
assert answer == "最终正文"
assert reasoning == "结构化思考字段"
class _EnabledStrategyRoleStore:
async def ensure_defaults(self, _rows) -> int:
return 0
async def list_roles(self, *, include_disabled: bool) -> list[dict[str, object]]:
del include_disabled
return [dict(row) for row in default_strategy_agent_roles()]
async def get_role(self, agent_id: str) -> dict[str, object]:
return {"id": agent_id, "enabled": True}
class _GlobalStrategyAgentStore:
async def get_any(self, agent_id: str) -> dict[str, object]:
return {"id": agent_id, "user_id": None}
def _parallel_request() -> SimpleNamespace:
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
strategy_agent_role_store=_EnabledStrategyRoleStore(),
agent_store=_GlobalStrategyAgentStore(),
)
)
)
def test_parallel_agent_stream_separates_inline_and_provider_reasoning(monkeypatch) -> None:
async def fake_run_one_agent(*_args, **_kwargs):
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': '<thi'}], ensure_ascii=False)}"
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': 'nk>内部分析</think>方案正文'}], ensure_ascii=False)}"
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': '', 'additional_kwargs': {'reasoning_content': '补充推理'}}], ensure_ascii=False)}"
yield {
"status": "done",
"content": "<think>内部分析</think>方案正文",
"agent_name": "strategy-selection-angle-security",
"tool_calls": [],
}
@asynccontextmanager
async def fake_client(*_args, **_kwargs):
yield object()
async def fake_create_thread(*_args, **_kwargs):
return "thread-id"
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
async def collect() -> list[dict[str, object]]:
response = await parallel_agents.run_parallel_agents_stream(
_parallel_request(),
{
"task_name": "test task",
"brief": "test brief",
"agent_ids": ["strategy-selection-angle-security"],
},
)
events: list[dict[str, object]] = []
async for chunk in response.body_iterator:
text = chunk.decode() if isinstance(chunk, bytes) else chunk
for line in text.splitlines():
if line.startswith("data:"):
events.append(json.loads(line[5:].strip()))
return events
events = asyncio.run(collect())
visible = "".join(str(event.get("text") or "") for event in events if event.get("type") == "delta")
thinking = "".join(str(event.get("text") or "") for event in events if event.get("type") == "reasoning_delta")
done = next(event for event in events if event.get("type") == "agent_done")
assert visible == "方案正文"
assert thinking == "内部分析补充推理"
assert done["content"] == "方案正文"
@pytest.mark.parametrize(
("buffered_content", "expected_error"),
[
@@ -214,7 +392,7 @@ def test_parallel_agent_truncated_stream_rejects_invalid_buffer(
async def collect() -> list[dict[str, object]]:
response = await parallel_agents.run_parallel_agents_stream(
SimpleNamespace(),
_parallel_request(),
{
"task_name": "test task",
"brief": "test brief",
@@ -236,6 +414,168 @@ def test_parallel_agent_truncated_stream_rejects_invalid_buffer(
assert expected_error in str(errors[0].get("message"))
def test_parallel_agent_plain_text_streams_each_chunk_without_probe_delay(
monkeypatch,
) -> None:
chunks = [
"This visible strategy ",
"includes objectives and execution paths, ",
"plus measurable acceptance criteria.",
]
async def fake_run_one_agent(*_args, **_kwargs):
for content in chunks:
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': content}], ensure_ascii=False)}"
@asynccontextmanager
async def fake_client(*_args, **_kwargs):
yield object()
async def fake_create_thread(*_args, **_kwargs):
return "thread-id"
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
monkeypatch.setattr(
parallel_agents,
"_loopback_base",
lambda _request: "http://gateway.test",
)
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
monkeypatch.setattr(
parallel_agents,
"_should_probe_implicit_reasoning",
lambda *_args: False,
)
async def collect() -> list[dict[str, object]]:
response = await parallel_agents.run_parallel_agents_stream(
_parallel_request(),
{
"task_name": "plain stream",
"brief": "plain answer chunks must stay live",
"agent_ids": ["strategy-selection-intent"],
},
)
events: list[dict[str, object]] = []
async for chunk in response.body_iterator:
text = chunk.decode() if isinstance(chunk, bytes) else chunk
for line in text.splitlines():
if line.startswith("data:"):
events.append(json.loads(line[5:].strip()))
return events
events = asyncio.run(collect())
assert [event["text"] for event in events if event.get("type") == "delta"] == chunks
done = next(event for event in events if event.get("type") == "agent_done")
assert done["content"] == "".join(chunks)
def test_parallel_agent_typed_reasoning_unlocks_live_answer_channel(monkeypatch) -> None:
chunks = [
"This visible strategy ",
"streams after typed reasoning, ",
"with objectives and acceptance criteria.",
]
async def fake_run_one_agent(*_args, **_kwargs):
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': '', 'additional_kwargs': {'reasoning_content': 'live reasoning'}}])}"
for content in chunks:
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': content}])}"
@asynccontextmanager
async def fake_client(*_args, **_kwargs):
yield object()
async def fake_create_thread(*_args, **_kwargs):
return "thread-id"
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
monkeypatch.setattr(parallel_agents, "_should_probe_implicit_reasoning", lambda *_args: True)
async def collect() -> list[dict[str, object]]:
response = await parallel_agents.run_parallel_agents_stream(
_parallel_request(),
{
"task_name": "typed reasoning stream",
"brief": "answer chunks must stay live",
"agent_ids": ["strategy-selection-intent"],
},
)
events: list[dict[str, object]] = []
async for chunk in response.body_iterator:
text = chunk.decode() if isinstance(chunk, bytes) else chunk
for line in text.splitlines():
if line.startswith("data:"):
events.append(json.loads(line[5:].strip()))
return events
events = asyncio.run(collect())
assert [event["text"] for event in events if event.get("type") == "delta"] == chunks
assert "".join(str(event.get("text") or "") for event in events if event.get("type") == "reasoning_delta") == "live reasoning"
def test_parallel_agent_truncated_close_only_stream_separates_reasoning(monkeypatch) -> None:
async def fake_run_one_agent(*_args, **_kwargs):
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': 'internal '}], ensure_ascii=False)}"
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': 'reasoning</thi'}], ensure_ascii=False)}"
yield "event: messages"
yield f"data: {json.dumps([{'type': 'ai', 'content': 'nk>This visible strategy includes objectives, execution paths, and acceptance criteria.'}], ensure_ascii=False)}"
@asynccontextmanager
async def fake_client(*_args, **_kwargs):
yield object()
async def fake_create_thread(*_args, **_kwargs):
return "thread-id"
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
monkeypatch.setattr(
parallel_agents,
"_should_probe_implicit_reasoning",
lambda *_args: True,
)
async def collect() -> list[dict[str, object]]:
response = await parallel_agents.run_parallel_agents_stream(
_parallel_request(),
{
"task_name": "test task",
"brief": "test brief",
"agent_ids": ["strategy-selection-intent"],
},
)
events: list[dict[str, object]] = []
async for chunk in response.body_iterator:
text = chunk.decode() if isinstance(chunk, bytes) else chunk
for line in text.splitlines():
if line.startswith("data:"):
events.append(json.loads(line[5:].strip()))
return events
events = asyncio.run(collect())
done = next(event for event in events if event.get("type") == "agent_done")
reasoning = "".join(str(event.get("text") or "") for event in events if event.get("type") == "reasoning_delta")
visible = "".join(str(event.get("text") or "") for event in events if event.get("type") == "delta")
assert done["content"] == ("This visible strategy includes objectives, execution paths, and acceptance criteria.")
assert visible == done["content"]
assert reasoning == "internal reasoning"
# ── 中间件给兜底 AIMessage 打标记 ────────────────────────────────────────────
@@ -0,0 +1,46 @@
from __future__ import annotations
from pathlib import Path
from deerflow.config.app_config import AppConfig
def test_relative_sandbox_mount_is_resolved_from_config_directory(tmp_path: Path) -> None:
knowledge_dir = tmp_path / "knowledge" / "knowledge_base"
knowledge_dir.mkdir(parents=True)
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
mounts:
- host_path: knowledge/knowledge_base
container_path: /mnt/knowledge
read_only: true
""".strip(),
encoding="utf-8",
)
config = AppConfig.from_file(str(config_path))
assert config.sandbox.mounts[0].host_path == str(knowledge_dir.resolve())
assert Path(config.sandbox.mounts[0].host_path).is_absolute()
def test_absolute_sandbox_mount_is_preserved(tmp_path: Path) -> None:
absolute_mount = (tmp_path / "shared").resolve()
config_data = {
"sandbox": {
"use": "deerflow.sandbox.local:LocalSandboxProvider",
"mounts": [
{
"host_path": str(absolute_mount),
"container_path": "/mnt/shared",
}
],
}
}
AppConfig._resolve_relative_mount_paths(config_data, tmp_path / "elsewhere")
assert config_data["sandbox"]["mounts"][0]["host_path"] == str(absolute_mount)
@@ -0,0 +1,667 @@
"""Global strategy-agent role CRUD, permissions and visibility tests."""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers import _strategy_selection_seed as seed
from app.gateway.routers import agents as agents_router
from app.gateway.routers import parallel_agents, strategy_agents
from deerflow.config import agents_config
from deerflow.config.paths import Paths
from deerflow.persistence.agents import MemoryAgentStore
from deerflow.persistence.skills.memory import MemorySkillStore
from deerflow.persistence.strategy_agent_roles import MemoryStrategyAgentRoleStore, StrategyAgentRoleRepository
from deerflow.persistence.strategy_agent_roles.model import StrategyAgentRoleRow
from deerflow.skills.types import Skill, SkillCategory
class _SkillStorage:
def __init__(self, skills: list[Skill]) -> None:
self._skills = skills
def load_skills(self, *, enabled_only: bool) -> list[Skill]:
if enabled_only:
return [skill for skill in self._skills if skill.enabled]
return list(self._skills)
def _skill(name: str, category: SkillCategory = SkillCategory.PUBLIC) -> Skill:
directory = Path("skills") / category.value / name
return Skill(
name=name,
description=name,
license=None,
skill_dir=directory,
skill_file=directory / "SKILL.md",
relative_path=Path(name),
category=category,
enabled=True,
)
def test_generic_agent_namespace_guard_is_case_insensitive_and_canonical() -> None:
assert agents_router._is_strategy_agent_id("STRATEGY-SELECTION-INTENT")
assert strategy_agents._process_lock("STRATEGY-SELECTION-INTENT") is strategy_agents._process_lock("strategy-selection-intent")
with pytest.raises(HTTPException) as exc_info:
agents_router._require_canonical_record_id(
"STRATEGY-SELECTION-INTENT",
{"id": "strategy-selection-intent"},
)
assert exc_info.value.status_code == 409
@pytest.mark.asyncio
async def test_generic_mutations_reject_strategy_namespace_before_storage(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
agents_router,
"get_agents_api_config",
lambda: SimpleNamespace(enabled=True),
)
request = SimpleNamespace()
with pytest.raises(HTTPException) as update_error:
await agents_router.update_agent(
seed.STRATEGY_INTENT_AGENT_ID.upper(),
agents_router.AgentUpdateRequest(name="bypass"),
request,
)
assert update_error.value.status_code == 409
assert "/api/strategy-agents/" in str(update_error.value.detail)
with pytest.raises(HTTPException) as delete_error:
await agents_router.delete_agent(
seed.STRATEGY_INTENT_AGENT_ID.upper(),
request,
)
assert delete_error.value.status_code == 409
assert "generic API" in str(delete_error.value.detail)
@pytest.mark.asyncio
async def test_strategy_mutation_rejects_case_insensitive_database_alias(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
canonical_id = seed.STRATEGY_INTENT_AGENT_ID
await role_store.ensure_defaults(seed.default_strategy_agent_roles())
role = await role_store.get_role(canonical_id)
record = await agent_store.get_any(canonical_id)
assert role is not None and record is not None
monkeypatch.setattr(strategy_agents, "_ensure_registry", lambda *_: _async_none())
monkeypatch.setattr(role_store, "get_role", lambda _: _async_value(role))
monkeypatch.setattr(agent_store, "get_any", lambda _: _async_value(record))
with pytest.raises(HTTPException) as exc_info:
await strategy_agents._get_role_and_agent(request, canonical_id.upper())
assert exc_info.value.status_code == 409
async def _async_none() -> None:
return None
async def _async_value(value):
return value
async def _request(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
*,
role: str = "admin",
) -> tuple[SimpleNamespace, MemoryAgentStore, MemoryStrategyAgentRoleStore]:
paths = Paths(tmp_path)
monkeypatch.setattr(seed, "get_paths", lambda: paths)
monkeypatch.setattr(agents_router, "get_paths", lambda: paths)
monkeypatch.setattr(strategy_agents, "get_paths", lambda: paths)
monkeypatch.setattr(agents_config, "get_paths", lambda: paths)
seed.ensure_strategy_selection_agents()
agent_store = MemoryAgentStore()
for agent_id in seed.required_agent_ids():
config = yaml.safe_load((paths.agent_dir(agent_id) / "config.yaml").read_text(encoding="utf-8"))
await agent_store.ensure_builtin(
{
"id": agent_id,
"name": config["name"],
"description": config.get("description") or "",
"published": True,
}
)
role_store = MemoryStrategyAgentRoleStore()
skill_store = MemorySkillStore()
storage = _SkillStorage(
[
_skill("public-skill"),
_skill("published-custom", SkillCategory.CUSTOM),
_skill("private-custom", SkillCategory.CUSTOM),
]
)
await skill_store.create(
{
"name": "published-custom",
"owner_user_id": "publisher",
"published": True,
}
)
await skill_store.create(
{
"name": "private-custom",
"owner_user_id": "owner",
"published": False,
}
)
monkeypatch.setattr(agents_router, "get_or_new_skill_storage", lambda **_: storage)
monkeypatch.setattr(agents_router, "get_skill_usage_entries", lambda: {})
state = SimpleNamespace(
agent_store=agent_store,
strategy_agent_role_store=role_store,
skill_store=skill_store,
config=SimpleNamespace(models=[SimpleNamespace(name="model-a")]),
)
request = SimpleNamespace(
app=SimpleNamespace(state=state),
state=SimpleNamespace(
user=SimpleNamespace(id="actor", system_role=role),
),
)
return request, agent_store, role_store
@pytest.mark.asyncio
async def test_catalog_has_fixed_categories_and_redacts_public_config(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, _, role_store = await _request(tmp_path, monkeypatch)
public = await strategy_agents.list_strategy_agents(request)
assert len(public.agents) == 9
assert [(item.category, item.fixed) for item in public.agents if item.fixed] == [
("intent", True),
("judge", True),
("solution", True),
]
assert all(item.model is None and item.soul is None and item.skills is None for item in public.agents)
await role_store.update_role(seed.STRATEGY_SECURITY_AGENT_ID, enabled=False)
active = await strategy_agents.list_strategy_agents(request)
assert seed.STRATEGY_SECURITY_AGENT_ID not in {item.id for item in active.agents}
admin = await strategy_agents.list_strategy_agents(
request,
include_disabled=True,
include_config=True,
)
disabled = next(item for item in admin.agents if item.id == seed.STRATEGY_SECURITY_AGENT_ID)
assert disabled.enabled is False
assert disabled.soul
assert disabled.skills == ["policy-knowledge"]
@pytest.mark.asyncio
async def test_catalog_rejects_user_owned_reserved_default(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, agent_store, _ = await _request(tmp_path, monkeypatch)
await agent_store.delete_builtin(seed.STRATEGY_INTENT_AGENT_ID)
await agent_store.create(
{
"id": seed.STRATEGY_INTENT_AGENT_ID,
"name": "collision",
"user_id": "attacker",
}
)
with pytest.raises(HTTPException) as exc_info:
await strategy_agents.list_strategy_agents(request)
assert exc_info.value.status_code == 409
assert "owned by a user" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_sensitive_catalog_and_writes_are_admin_only(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, _, _ = await _request(tmp_path, monkeypatch, role="user")
# The active catalog is intentionally readable by the workbench.
assert len((await strategy_agents.list_strategy_agents(request)).agents) == 9
with pytest.raises(HTTPException) as list_error:
await strategy_agents.list_strategy_agents(
request,
include_disabled=True,
include_config=True,
)
assert list_error.value.status_code == 403
with pytest.raises(HTTPException) as create_error:
await strategy_agents.create_strategy_angle(
strategy_agents.StrategyAngleCreateRequest(name="非法新增"),
request,
)
assert create_error.value.status_code == 403
@pytest.mark.asyncio
async def test_angle_crud_is_global_and_delete_is_soft(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
created = await strategy_agents.create_strategy_angle(
strategy_agents.StrategyAngleCreateRequest(
name="科技产业视角",
description="评估关键技术和产业链韧性",
soul="你是科技产业策略分析智能体。",
model="model-a",
skills=["public-skill", "published-custom"],
),
request,
)
assert created.category == "angle"
assert created.fixed is False
assert created.enabled is True
assert created.id.startswith(seed.STRATEGY_ANGLE_AGENT_PREFIX)
assert created.model == "model-a"
assert created.skills == ["public-skill", "published-custom"]
updated = await strategy_agents.update_strategy_agent(
created.id,
strategy_agents.StrategyAgentUpdateRequest(
name="科技与产业链视角",
enabled=False,
sort_order=123,
skills=["public-skill"],
),
request,
)
assert updated.name == "科技与产业链视角"
assert updated.enabled is False
assert updated.sort_order == 123
assert updated.skills == ["public-skill"]
deleted = await strategy_agents.disable_strategy_angle(created.id, request)
assert deleted.enabled is False
# Soft delete protects saved projects and permits an administrator to
# restore the angle without recreating its ID or files.
assert await agent_store.get_any(created.id) is not None
assert await role_store.get_role(created.id) is not None
restored = await strategy_agents.update_strategy_agent(
created.id,
strategy_agents.StrategyAgentUpdateRequest(enabled=True),
request,
)
assert restored.enabled is True
assert (tmp_path / "agents" / created.id / ".strategy-role.yaml").is_file()
@pytest.mark.asyncio
async def test_memory_mode_recovers_dynamic_angle_from_folder_sidecar(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, _, _ = await _request(tmp_path, monkeypatch)
created = await strategy_agents.create_strategy_angle(
strategy_agents.StrategyAngleCreateRequest(
name="恢复测试角度",
sort_order=177,
),
request,
)
await strategy_agents.disable_strategy_angle(created.id, request)
request.app.state.agent_store = MemoryAgentStore()
request.app.state.strategy_agent_role_store = MemoryStrategyAgentRoleStore()
recovered = await strategy_agents.list_strategy_agents(
request,
include_disabled=True,
include_config=True,
)
item = next(entry for entry in recovered.agents if entry.id == created.id)
assert item.name == "恢复测试角度"
assert item.enabled is False
assert item.sort_order == 177
assert (await request.app.state.agent_store.get_any(created.id))["user_id"] is None
@pytest.mark.asyncio
async def test_memory_mode_sidecar_keeps_default_angle_disabled_after_restart(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, _, _ = await _request(tmp_path, monkeypatch)
await strategy_agents.update_strategy_agent(
seed.STRATEGY_SECURITY_AGENT_ID,
strategy_agents.StrategyAgentUpdateRequest(enabled=False, sort_order=188),
request,
)
request.app.state.agent_store = MemoryAgentStore()
request.app.state.strategy_agent_role_store = MemoryStrategyAgentRoleStore()
with pytest.raises(HTTPException) as run_error:
await parallel_agents.run_parallel_agents_stream(
request,
{
"task_name": "restart gate",
"brief": "disabled default must stay disabled",
"agent_ids": [seed.STRATEGY_SECURITY_AGENT_ID],
},
)
assert run_error.value.status_code == 409
recovered = await strategy_agents.list_strategy_agents(
request,
include_disabled=True,
include_config=True,
)
item = next(entry for entry in recovered.agents if entry.id == seed.STRATEGY_SECURITY_AGENT_ID)
assert item.enabled is False
assert item.sort_order == 188
@pytest.mark.asyncio
async def test_cancelled_create_compensates_every_persistence_layer(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
generated_id = f"{seed.STRATEGY_ANGLE_AGENT_PREFIX}cancelled"
monkeypatch.setattr(
strategy_agents,
"uuid4",
lambda: SimpleNamespace(hex="cancelled"),
)
async def cancel_set_extras(*_args, **_kwargs):
raise asyncio.CancelledError
monkeypatch.setattr(agent_store, "set_extras", cancel_set_extras)
with pytest.raises(asyncio.CancelledError):
await strategy_agents.create_strategy_angle(
strategy_agents.StrategyAngleCreateRequest(name="cancelled create"),
request,
)
assert await agent_store.get_any(generated_id) is None
assert await role_store.get_role(generated_id) is None
assert not (tmp_path / "agents" / generated_id).exists()
@pytest.mark.asyncio
async def test_cross_process_file_lock_excludes_second_handle(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
paths = Paths(tmp_path)
monkeypatch.setattr(strategy_agents, "get_paths", lambda: paths)
first = strategy_agents._open_cross_process_lock(seed.STRATEGY_INTENT_AGENT_ID)
second = strategy_agents._open_cross_process_lock(seed.STRATEGY_INTENT_AGENT_ID)
try:
assert strategy_agents._try_cross_process_lock(first) is True
assert strategy_agents._try_cross_process_lock(second) is False
strategy_agents._release_cross_process_lock(first)
first = None
assert strategy_agents._try_cross_process_lock(second) is True
finally:
if first is not None:
strategy_agents._release_cross_process_lock(first)
if not second.closed:
strategy_agents._release_cross_process_lock(second)
@pytest.mark.asyncio
async def test_update_failure_restores_files_cache_and_role(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
created = await strategy_agents.create_strategy_angle(
strategy_agents.StrategyAngleCreateRequest(
name="事务前名称",
model="model-a",
skills=["public-skill"],
sort_order=166,
),
request,
)
agent_dir = tmp_path / "agents" / created.id
before_files = {name: (agent_dir / name).read_text(encoding="utf-8") for name in ("config.yaml", "SOUL.md", ".strategy-role.yaml")}
real_update = role_store.update_role
failed = False
async def fail_once(agent_id: str, **kwargs):
nonlocal failed
if kwargs.get("sort_order") == 188 and not failed:
failed = True
raise RuntimeError("injected role-store failure")
return await real_update(agent_id, **kwargs)
monkeypatch.setattr(role_store, "update_role", fail_once)
with pytest.raises(RuntimeError, match="injected role-store failure"):
await strategy_agents.update_strategy_agent(
created.id,
strategy_agents.StrategyAgentUpdateRequest(
name="不应留下的名称",
soul="不应留下的 SOUL",
skills=[],
sort_order=188,
),
request,
)
record = await agent_store.get_any(created.id)
role = await role_store.get_role(created.id)
assert record is not None and record["name"] == "事务前名称"
assert role is not None and role["sort_order"] == 166
assert (await agent_store.get_extras_for([created.id]))[created.id]["skills"] == ["public-skill"]
assert {name: (agent_dir / name).read_text(encoding="utf-8") for name in ("config.yaml", "SOUL.md", ".strategy-role.yaml")} == before_files
@pytest.mark.asyncio
async def test_fixed_roles_cannot_be_disabled_or_deleted(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, _, _ = await _request(tmp_path, monkeypatch)
with pytest.raises(HTTPException) as update_error:
await strategy_agents.update_strategy_agent(
seed.STRATEGY_INTENT_AGENT_ID,
strategy_agents.StrategyAgentUpdateRequest(enabled=False),
request,
)
assert update_error.value.status_code == 409
with pytest.raises(HTTPException) as delete_error:
await strategy_agents.disable_strategy_angle(
seed.STRATEGY_JUDGE_AGENT_ID,
request,
)
assert delete_error.value.status_code == 409
@pytest.mark.asyncio
async def test_model_and_global_skill_visibility_are_validated(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, _, _ = await _request(tmp_path, monkeypatch)
with pytest.raises(HTTPException) as model_error:
await strategy_agents.create_strategy_angle(
strategy_agents.StrategyAngleCreateRequest(
name="未知模型",
model="not-configured",
),
request,
)
assert model_error.value.status_code == 422
with pytest.raises(HTTPException) as skill_error:
await strategy_agents.create_strategy_angle(
strategy_agents.StrategyAngleCreateRequest(
name="私有技能",
skills=["private-custom"],
),
request,
)
assert skill_error.value.status_code == 422
assert "globally available" in str(skill_error.value.detail)
defaulted = await strategy_agents.create_strategy_angle(
strategy_agents.StrategyAngleCreateRequest(
name="默认角色模板",
description="关注跨领域风险",
),
request,
)
assert defaulted.soul
assert "默认角色模板" in defaulted.soul
assert "不替代其他角度" in defaulted.soul
@pytest.mark.asyncio
async def test_disabled_strategy_agent_cannot_be_started_via_parallel_api(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, _, role_store = await _request(tmp_path, monkeypatch)
await role_store.ensure_defaults(seed.default_strategy_agent_roles())
await role_store.update_role(seed.STRATEGY_SECURITY_AGENT_ID, enabled=False)
with pytest.raises(HTTPException) as exc_info:
await parallel_agents.run_parallel_agents_stream(
request,
{
"task_name": "测试",
"brief": "验证停用闸门",
"agent_ids": [seed.STRATEGY_SECURITY_AGENT_ID],
},
)
assert exc_info.value.status_code == 409
@pytest.mark.asyncio
async def test_parallel_api_rejects_user_owned_strategy_backing_agent(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
request, agent_store, role_store = await _request(tmp_path, monkeypatch)
await role_store.ensure_defaults(seed.default_strategy_agent_roles())
await agent_store.delete_builtin(seed.STRATEGY_SECURITY_AGENT_ID)
await agent_store.create(
{
"id": seed.STRATEGY_SECURITY_AGENT_ID,
"name": "collision",
"user_id": "attacker",
}
)
with pytest.raises(HTTPException) as exc_info:
await parallel_agents.run_parallel_agents_stream(
request,
{
"task_name": "测试",
"brief": "验证归属闸门",
"agent_ids": [seed.STRATEGY_SECURITY_AGENT_ID],
},
)
assert exc_info.value.status_code == 409
assert "owned by a user" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_memory_registry_repairs_corrupted_fixed_role_metadata() -> None:
store = MemoryStrategyAgentRoleStore()
await store.create_role(
{
"agent_id": seed.STRATEGY_INTENT_AGENT_ID,
"category": "angle",
"fixed": False,
"enabled": False,
"sort_order": 999,
}
)
await store.ensure_defaults(seed.default_strategy_agent_roles())
repaired = await store.get_role(seed.STRATEGY_INTENT_AGENT_ID)
assert repaired is not None
assert repaired["category"] == "intent"
assert repaired["fixed"] is True
assert repaired["enabled"] is True
assert repaired["sort_order"] == 0
@pytest.mark.asyncio
async def test_sql_registry_preserves_disabled_defaults_and_soft_records(
tmp_path: Path,
) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'strategy-agent-roles.db'}")
async with engine.begin() as connection:
await connection.run_sync(StrategyAgentRoleRow.__table__.create)
store = StrategyAgentRoleRepository(async_sessionmaker(engine, expire_on_commit=False))
try:
defaults = seed.default_strategy_agent_roles()
assert await store.ensure_defaults(defaults) == 9
assert await store.ensure_defaults(defaults) == 0
await store.update_role(seed.STRATEGY_SECURITY_AGENT_ID, enabled=False)
# Re-seeding must never undo an administrator's soft delete.
assert await store.ensure_defaults(defaults) == 0
disabled = await store.get_role(seed.STRATEGY_SECURITY_AGENT_ID)
assert disabled is not None
assert disabled["enabled"] is False
# Canonical fixed-role invariants are repaired, unlike angle lifecycle
# state which intentionally remains administrator-controlled.
async with store._sf() as session:
row = await session.get(
StrategyAgentRoleRow,
seed.STRATEGY_INTENT_AGENT_ID,
)
row.category = "angle"
row.fixed = False
row.enabled = False
row.sort_order = 999
await session.commit()
assert await store.ensure_defaults(defaults) == 0
repaired = await store.get_role(seed.STRATEGY_INTENT_AGENT_ID)
assert repaired is not None
assert repaired["category"] == "intent"
assert repaired["fixed"] is True
assert repaired["enabled"] is True
assert repaired["sort_order"] == 0
dynamic = await store.create_role(
{
"agent_id": "strategy-selection-angle-sql-test",
"category": "angle",
"fixed": False,
"enabled": True,
"sort_order": 150,
}
)
assert dynamic["category"] == "angle"
assert await store.update_role(dynamic["agent_id"], enabled=False)
assert await store.delete_role(dynamic["agent_id"]) is True
assert await store.get_role(dynamic["agent_id"]) is None
finally:
await engine.dispose()
@@ -0,0 +1,81 @@
import asyncio
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers import parallel_agent_tasks
from deerflow.persistence.base import Base
from deerflow.persistence.parallel_agent_tasks import ParallelAgentTaskRepository
@pytest.mark.asyncio
async def test_parallel_task_repository_rejects_stale_revision(tmp_path) -> None:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'strategy-project-cas.db'}")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
store = ParallelAgentTaskRepository(async_sessionmaker(engine, expire_on_commit=False))
try:
original = await store.create_task(
"user-1",
{
"id": "strategy-selection-cas",
"title": "original",
"messages": [{"snapshot": "original"}],
},
)
await asyncio.sleep(0.002)
newer = await store.update_task(
"strategy-selection-cas",
"user-1",
title="newer",
messages=[{"snapshot": "newer"}],
expected_updated_at=original["updated_at"],
)
assert newer is not None
stale = await store.update_task(
"strategy-selection-cas",
"user-1",
title="stale",
messages=[{"snapshot": "stale"}],
expected_updated_at=original["updated_at"],
)
assert stale is None
final = await store.get_task("strategy-selection-cas", "user-1")
assert final is not None
assert final["title"] == "newer"
assert final["messages"] == [{"snapshot": "newer"}]
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_parallel_task_route_reports_revision_conflict() -> None:
class ConflictStore:
async def update_task(self, *_args, **_kwargs):
return None
async def get_task(self, *_args, **_kwargs):
return {"id": "strategy-selection-cas"}
request = SimpleNamespace(
state=SimpleNamespace(user=SimpleNamespace(id="user-1")),
app=SimpleNamespace(state=SimpleNamespace(parallel_agent_task_store=ConflictStore())),
)
body = parallel_agent_tasks.TaskUpdateRequest(
title="stale",
expected_updated_at=datetime.now(UTC),
)
with pytest.raises(HTTPException) as error:
await parallel_agent_tasks.update_task(
request,
"strategy-selection-cas",
body,
)
assert error.value.status_code == 409
assert "其他页面更新" in str(error.value.detail)
@@ -0,0 +1,72 @@
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.gateway.routers import system_settings as system_settings_router
from deerflow.config.system_settings import SystemSettings
def test_strategy_project_demo_is_hidden_by_default() -> None:
assert SystemSettings().strategy_projects.taiwan_strait_demo_enabled is False
@pytest.mark.asyncio
async def test_non_admin_cannot_update_strategy_project_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def ordinary_user(_request):
return SimpleNamespace(system_role="user")
monkeypatch.setattr(
system_settings_router,
"get_optional_user_from_request",
ordinary_user,
)
with pytest.raises(HTTPException) as error:
await system_settings_router.update_strategy_project_settings(
system_settings_router.StrategyProjectSettingsUpdateRequest(
taiwan_strait_demo_enabled=True,
),
SimpleNamespace(),
)
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_admin_can_update_strategy_project_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
current = SystemSettings()
saved: list[SystemSettings] = []
async def admin_user(_request):
return SimpleNamespace(system_role="admin")
monkeypatch.setattr(
system_settings_router,
"get_optional_user_from_request",
admin_user,
)
monkeypatch.setattr(
system_settings_router,
"load_system_settings",
lambda: current,
)
monkeypatch.setattr(
system_settings_router,
"save_system_settings",
lambda settings: saved.append(settings) or settings,
)
response = await system_settings_router.update_strategy_project_settings(
system_settings_router.StrategyProjectSettingsUpdateRequest(
taiwan_strait_demo_enabled=True,
),
SimpleNamespace(),
)
assert response.taiwan_strait_demo_enabled is True
assert saved[0].strategy_projects.taiwan_strait_demo_enabled is True
@@ -131,6 +131,34 @@ def test_seed_creates_once_and_never_overwrites_admin_changes(tmp_paths: Paths)
assert customized_soul.read_text(encoding="utf-8") == "管理员现场修改"
def test_seed_upgrades_only_an_exact_known_legacy_default(
tmp_paths: Paths,
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert seed.ensure_strategy_selection_agents()
intent_soul = tmp_paths.agent_dir(seed.STRATEGY_INTENT_AGENT_ID) / "SOUL.md"
legacy_content = b"legacy bundled intent soul\r\n"
intent_soul.write_bytes(legacy_content)
monkeypatch.setitem(
seed._LEGACY_DEFAULT_SOUL_HASHES,
seed.STRATEGY_INTENT_AGENT_ID,
seed._normalized_text_hash(legacy_content),
)
assert seed.ensure_strategy_selection_agents() == []
assert (
intent_soul.read_bytes()
== _asset(
seed.STRATEGY_INTENT_AGENT_ID,
"SOUL.md",
).read_bytes()
)
intent_soul.write_text("管理员现场修改", encoding="utf-8")
assert seed.ensure_strategy_selection_agents() == []
assert intent_soul.read_text(encoding="utf-8") == "管理员现场修改"
class _SkillStorage:
def __init__(self, skills: list[Skill]) -> None:
self._skills = skills