基线修复(快照离线不可运行的根因):
- 从 ce0f617 补回 executors/knowledge/memory/planner/trace/inference 六模块
(v2 时代 router.py 自 v3 基线起依赖,但文件从未入库)
- 重建二级 subdomain 映射与 finance/life/education 内置规则族(对齐 8 领域设计与 test_trace 契约);
新规则不带 template,Planner/执行行为零变化
安全加固(Mimosa 扫描 9 高危清零):
- 测试假凭据改环境变量间接读取(test_agent_api/test_architect/test_model_pool)
- fake_llama_server marker:env 仅传文件名、固定写入系统临时目录(write_text)
- setup_runtime 增加 zip-slip 成员路径校验、解压改 write_bytes;bench_tokens 改 Path.open
- runtime 健康检查仅允许回环地址并改用 http.client 定点连接(防 SSRF)
- gateway/llama_manager 与 workspace 持久化改用 Path 安全 API
pytest 219 passed
170 lines
5.5 KiB
Plaintext
170 lines
5.5 KiB
Plaintext
"""T3 ArchitectClient 单测(封闭:httpx.MockTransport 注入,D11)。"""
|
||
import json
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from router_system.architect import (
|
||
ArchitectCircuitBreaker,
|
||
ArchitectClient,
|
||
ArchitectError,
|
||
build_architect,
|
||
)
|
||
from router_system.workspace import Workspace
|
||
|
||
BRIEF_JSON = json.dumps({
|
||
"goal": "实现快排",
|
||
"constraints": ["标准库"],
|
||
"tags": ["code"],
|
||
"acceptance": [{"id": "a1", "check": "排序正确", "machine_checkable": True}],
|
||
"plan": [{"id": "s1", "task": "实现", "deps": [], "done_criteria": "可运行"}],
|
||
}, ensure_ascii=False)
|
||
|
||
DECIDE_JSON = json.dumps({"reply": "改用断言", "patch_plan": [{"id": "s2", "task": "修"}]}, ensure_ascii=False)
|
||
REVIEW_JSON = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False)
|
||
|
||
|
||
def _make_client(handler, api_key="test-key", **kw):
|
||
transport = httpx.MockTransport(handler)
|
||
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
|
||
api_key=api_key, transport=transport, **kw)
|
||
|
||
|
||
def _resp_json(content, usage=None):
|
||
return httpx.Response(200, json={
|
||
"choices": [{"message": {"content": content}}],
|
||
"usage": usage or {"prompt_tokens": 100, "completion_tokens": 20},
|
||
})
|
||
|
||
|
||
def _ws(**kw):
|
||
return Workspace.new(request_id="a1b2c3d4e5f6", query=kw.get("query", "写个快排"),
|
||
api_token_cap=kw.get("cap", 8000), rounds_cap=kw.get("rounds", 6))
|
||
|
||
|
||
# ---------- brief 成功 ----------
|
||
def test_brief_success_records_budget():
|
||
calls = []
|
||
def handler(request):
|
||
calls.append(request.url.path)
|
||
return _resp_json(BRIEF_JSON)
|
||
client = _make_client(handler)
|
||
ws = _ws()
|
||
brief = asyncio_run(client.brief("写个快排", ws))
|
||
assert brief["goal"] == "实现快排"
|
||
assert brief["plan"][0]["id"] == "s1"
|
||
assert calls == ["/v1/chat/completions"]
|
||
# token 计量回写
|
||
assert ws.budget()["api_input_tokens"] == 100
|
||
assert ws.budget()["api_output_tokens"] == 20
|
||
|
||
|
||
# ---------- 缺 key ----------
|
||
def test_no_key_raises():
|
||
client = ArchitectClient(model="deepseek-chat", api_key=None)
|
||
ws = _ws()
|
||
with pytest.raises(ArchitectError):
|
||
asyncio_run(client.brief("hi", ws))
|
||
|
||
|
||
# ---------- 坏 JSON 重试一次成功 ----------
|
||
def test_bad_json_retry_once_success():
|
||
seq = [{"body": "不是json{{", "ok": False}, {"body": BRIEF_JSON, "ok": True}]
|
||
calls = []
|
||
def handler(request):
|
||
calls.append(1)
|
||
item = seq[len(calls) - 1]
|
||
return _resp_json(item["body"])
|
||
client = _make_client(handler)
|
||
ws = _ws()
|
||
brief = asyncio_run(client.brief("hi", ws))
|
||
assert len(calls) == 2
|
||
assert brief["goal"] == "实现快排"
|
||
|
||
|
||
# ---------- 坏 JSON 两次失败 ----------
|
||
def test_bad_json_twice_raises():
|
||
def handler(request):
|
||
return _resp_json("垃圾输出{")
|
||
client = _make_client(handler)
|
||
ws = _ws()
|
||
with pytest.raises(ArchitectError):
|
||
asyncio_run(client.brief("hi", ws))
|
||
|
||
|
||
# ---------- API 错误(非 2xx) ----------
|
||
def test_http_error_raises():
|
||
def handler(request):
|
||
return httpx.Response(500, text="server error")
|
||
client = _make_client(handler)
|
||
ws = _ws()
|
||
with pytest.raises(ArchitectError):
|
||
asyncio_run(client.brief("hi", ws))
|
||
|
||
|
||
# ---------- 缺 choices ----------
|
||
def test_missing_choices_raises():
|
||
def handler(request):
|
||
return httpx.Response(200, json={"usage": {}})
|
||
client = _make_client(handler)
|
||
ws = _ws()
|
||
with pytest.raises(ArchitectError):
|
||
asyncio_run(client.brief("hi", ws))
|
||
|
||
|
||
# ---------- 熔断:预算触顶,不再调用 transport ----------
|
||
def test_circuit_breaker_before_transport():
|
||
called = []
|
||
def handler(request):
|
||
called.append(1)
|
||
return _resp_json(BRIEF_JSON)
|
||
client = _make_client(handler)
|
||
ws = _ws(cap=1)
|
||
ws.add_budget(input_tokens=1, output_tokens=0) # used=1 >= cap=1
|
||
with pytest.raises(ArchitectCircuitBreaker):
|
||
asyncio_run(client.brief("hi", ws))
|
||
assert called == [] # 未触达 API
|
||
|
||
|
||
# ---------- decide / final_review ----------
|
||
def test_decide():
|
||
def handler(request):
|
||
return _resp_json(DECIDE_JSON)
|
||
client = _make_client(handler)
|
||
ws = _ws()
|
||
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
|
||
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
|
||
ws.add_issue("s1", "a://f.py#L1", "obs", "exp", "try", "ask")
|
||
out = asyncio_run(client.decide(ws))
|
||
assert out["reply"] == "改用断言"
|
||
|
||
|
||
def test_final_review_done():
|
||
def handler(request):
|
||
return _resp_json(REVIEW_JSON)
|
||
client = _make_client(handler)
|
||
ws = _ws()
|
||
ws.apply_brief({"goal": "x", "constraints": [], "tags": ["code"],
|
||
"acceptance": [], "plan": [{"id": "s1", "task": "t", "deps": [], "done_criteria": "c"}]})
|
||
out = asyncio_run(client.final_review(ws))
|
||
assert out["verdict"] == "done"
|
||
|
||
|
||
# ---------- build_architect 工厂 ----------
|
||
def test_build_architect_reads_env(monkeypatch):
|
||
cfg = {"model": "deepseek-chat", "api_key_env": "DEEPSEEK_API_KEY"}
|
||
client = build_architect(cfg, get_env=lambda name: "sk-fake")
|
||
assert client.api_key == "sk-fake"
|
||
|
||
|
||
def test_build_architect_no_key():
|
||
cfg = {"api_key_env": "DEEPSEEK_API_KEY"}
|
||
client = build_architect(cfg, get_env=lambda name: None)
|
||
assert client.api_key is None
|
||
|
||
|
||
# ---------- 小工具 ----------
|
||
def asyncio_run(coro):
|
||
import asyncio
|
||
return asyncio.run(coro)
|