feat: 完善策略优选项目工作流
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user