- 删除子智能体 search_workspace 工具定义、路由、formatter 与测试 - 传统/多智能体模式共用 SUB_AGENT_TOOLS,一处删除同时生效 - 老对话恢复后再调用 search_workspace 会返回 未知工具 错误 - 清理多智能体排查期间加入的 memory_debug 模块及所有调用点 (modules/memory_debug.py 删除,server/ core/ utils/ modules/sub_agent/ 中相关埋点全部移除) - 顺手删除 SubAgentActivityDialog 中 search_workspace 的历史显示文案
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""子智能体 Python 重写回归测试(mock 模型)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List
|
||
|
||
import sys
|
||
# 测试脚本在 test/ 目录下,需要把项目根目录加入路径
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
|
||
# 把子智能体运行态数据重定向到临时目录,避免写入受保护的 ~/.agents 路径
|
||
_test_tmp = Path(tempfile.mkdtemp(prefix="sub_agent_cfg_"))
|
||
os.environ["SUB_AGENT_TASKS_BASE_DIR"] = str(_test_tmp / "tasks")
|
||
os.environ["SUB_AGENT_STATE_FILE"] = str(_test_tmp / "state.json")
|
||
os.environ["SUB_AGENT_PROJECT_RESULTS_DIR"] = str(_test_tmp / "results")
|
||
|
||
from modules.sub_agent import SubAgentManager
|
||
from utils.api_client import DeepSeekClient
|
||
|
||
|
||
class FakeTerminal:
|
||
"""模拟 WebTerminal,只实现 handle_tool_call。"""
|
||
|
||
def __init__(self, project_path: Path):
|
||
self.project_path = project_path
|
||
|
||
async def handle_tool_call(self, name: str, arguments: Dict[str, Any]) -> str:
|
||
if name == "write_file":
|
||
path = self.project_path / arguments["file_path"]
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
mode = "a" if arguments.get("append") else "w"
|
||
with open(path, mode, encoding="utf-8") as f:
|
||
f.write(arguments["content"])
|
||
return json.dumps({"success": True, "message": "写入成功", "path": str(path)})
|
||
|
||
if name == "edit_file":
|
||
path = self.project_path / arguments["file_path"]
|
||
content = path.read_text(encoding="utf-8")
|
||
count = 0
|
||
for repl in arguments.get("replacements", []):
|
||
old = repl["old_string"]
|
||
new = repl["new_string"]
|
||
if repl.get("replace_all"):
|
||
n = content.count(old)
|
||
content = content.replace(old, new)
|
||
count += n
|
||
else:
|
||
content = content.replace(old, new, 1)
|
||
count += 1
|
||
path.write_text(content, encoding="utf-8")
|
||
return json.dumps({"success": True, "path": str(path), "replacements": count})
|
||
|
||
if name == "read_file":
|
||
path = self.project_path / arguments["path"]
|
||
if not path.exists():
|
||
return json.dumps({"success": False, "error": f"文件不存在: {path}"})
|
||
text = path.read_text(encoding="utf-8")
|
||
return json.dumps({"success": True, "type": "read", "content": text})
|
||
|
||
if name == "run_command":
|
||
import subprocess
|
||
cmd = arguments["command"]
|
||
timeout = arguments.get("timeout", 30)
|
||
cwd = arguments.get("working_dir")
|
||
if cwd:
|
||
cwd = self.project_path / cwd
|
||
# mock 中不真正执行长时间 sleep,避免阻塞事件循环
|
||
if cmd.strip().startswith("sleep "):
|
||
return json.dumps({"success": True, "output": f"mock: {cmd}"})
|
||
try:
|
||
proc = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
||
output = (proc.stdout or "") + (proc.stderr or "")
|
||
return json.dumps({"success": proc.returncode == 0, "output": output})
|
||
except Exception as exc:
|
||
return json.dumps({"success": False, "output": str(exc)})
|
||
|
||
return json.dumps({"success": False, "error": f"未实现的工具: {name}"})
|
||
|
||
|
||
class FakeModel:
|
||
"""按脚本驱动 DeepSeekClient.chat 的返回。"""
|
||
|
||
def __init__(self, responses: List[List[Dict[str, Any]]]):
|
||
self.responses = responses
|
||
self.call_index = 0
|
||
|
||
async def chat(self, messages, *, tools=None, stream=True, **kwargs):
|
||
if self.call_index >= len(self.responses):
|
||
chunks = [{"choices": [{"delta": {"content": ""}}]}]
|
||
else:
|
||
chunks = self.responses[self.call_index]
|
||
self.call_index += 1
|
||
for chunk in chunks:
|
||
yield chunk
|
||
|
||
|
||
def make_tool_chunk(tool_calls: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||
return {
|
||
"choices": [{
|
||
"delta": {
|
||
"tool_calls": [
|
||
{"index": i, "id": f"call_{i}", "function": {"name": tc["name"], "arguments": tc["args"]}}
|
||
for i, tc in enumerate(tool_calls)
|
||
]
|
||
}
|
||
}]
|
||
}
|
||
|
||
|
||
def make_finish_chunk() -> Dict[str, Any]:
|
||
return {
|
||
"choices": [{"delta": {"content": ""}}]
|
||
}
|
||
|
||
|
||
def install_fake_model(responses: List[List[Dict[str, Any]]]):
|
||
fake = FakeModel(responses)
|
||
original = DeepSeekClient.chat
|
||
DeepSeekClient.chat = fake.chat
|
||
return fake, original
|
||
|
||
|
||
def restore_model(original):
|
||
DeepSeekClient.chat = original
|
||
|
||
|
||
def test_write_file_and_edit():
|
||
"""测试 write_file 与 edit_file(多处替换 + replace_all)。"""
|
||
tmp = Path(tempfile.mkdtemp(prefix="sub_agent_test_"))
|
||
try:
|
||
manager = SubAgentManager(str(tmp), str(tmp / "data"))
|
||
terminal = FakeTerminal(tmp)
|
||
manager.set_terminal(terminal)
|
||
|
||
# 模型调用序列:先 write_file,再 edit_file 多处替换,最后 finish
|
||
responses = [
|
||
[make_tool_chunk([{
|
||
"name": "write_file",
|
||
"args": json.dumps({"file_path": "demo.txt", "content": "aaa bbb aaa ccc\n"})
|
||
}])],
|
||
[make_tool_chunk([{
|
||
"name": "edit_file",
|
||
"args": json.dumps({
|
||
"file_path": "demo.txt",
|
||
"replacements": [
|
||
{"old_string": "bbb", "new_string": "BBB"},
|
||
{"old_string": "aaa", "new_string": "AAA", "replace_all": True},
|
||
]
|
||
})
|
||
}])],
|
||
[make_tool_chunk([{
|
||
"name": "finish_task",
|
||
"args": json.dumps({"success": True, "summary": "已完成写入与编辑测试"})
|
||
}])],
|
||
]
|
||
_, original = install_fake_model(responses)
|
||
try:
|
||
result = manager.create_sub_agent(
|
||
agent_id=1,
|
||
summary="写入编辑测试",
|
||
task="测试 write_file 与 edit_file",
|
||
deliverables_dir="deliverables",
|
||
timeout_seconds=30,
|
||
conversation_id="conv-test-1",
|
||
thinking_mode="fast",
|
||
)
|
||
assert result["success"], result
|
||
task_id = result["task_id"]
|
||
final = manager.wait_for_completion(task_id=task_id, timeout_seconds=10)
|
||
assert final["status"] == "completed", final
|
||
|
||
content = (tmp / "demo.txt").read_text(encoding="utf-8")
|
||
assert content == "AAA BBB AAA ccc\n", f"内容不符: {content!r}"
|
||
assert (tmp / "deliverables").exists()
|
||
finally:
|
||
restore_model(original)
|
||
finally:
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
|
||
def test_read_mediafile():
|
||
"""测试 read_mediafile 返回 base64。"""
|
||
tmp = Path(tempfile.mkdtemp(prefix="sub_agent_test_"))
|
||
try:
|
||
manager = SubAgentManager(str(tmp), str(tmp / "data"))
|
||
terminal = FakeTerminal(tmp)
|
||
manager.set_terminal(terminal)
|
||
|
||
# 创建一个最小 PNG:1x1 透明像素
|
||
png_bytes = bytes.fromhex(
|
||
"89504e470d0a1a0a0000000d49484452"
|
||
"000000010000000108060000001f15c4"
|
||
"890000000d4944415478da6300180000"
|
||
"03010005fe027f0000000049454e44ae"
|
||
"426082"
|
||
)
|
||
(tmp / "pixel.png").write_bytes(png_bytes)
|
||
|
||
responses = [
|
||
[make_tool_chunk([{
|
||
"name": "read_mediafile",
|
||
"args": json.dumps({"path": "pixel.png"})
|
||
}])],
|
||
[make_tool_chunk([{
|
||
"name": "finish_task",
|
||
"args": json.dumps({"success": True, "summary": "媒体读取完成"})
|
||
}])],
|
||
]
|
||
_, original = install_fake_model(responses)
|
||
try:
|
||
result = manager.create_sub_agent(
|
||
agent_id=3,
|
||
summary="媒体测试",
|
||
task="测试 read_mediafile",
|
||
deliverables_dir="deliverables3",
|
||
timeout_seconds=30,
|
||
conversation_id="conv-test-3",
|
||
thinking_mode="fast",
|
||
)
|
||
assert result["success"], result
|
||
final = manager.wait_for_completion(task_id=result["task_id"], timeout_seconds=10)
|
||
assert final["status"] == "completed", final
|
||
finally:
|
||
restore_model(original)
|
||
finally:
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
|
||
def test_terminate():
|
||
"""测试手动终止子智能体。"""
|
||
tmp = Path(tempfile.mkdtemp(prefix="sub_agent_test_"))
|
||
try:
|
||
manager = SubAgentManager(str(tmp), str(tmp / "data"))
|
||
terminal = FakeTerminal(tmp)
|
||
manager.set_terminal(terminal)
|
||
|
||
# 模型先调用一次 run_command,之后返回空内容,使任务持续运行
|
||
responses = [
|
||
[make_tool_chunk([{
|
||
"name": "run_command",
|
||
"args": json.dumps({"command": "sleep 1000", "timeout": 1200})
|
||
}])],
|
||
[make_finish_chunk()],
|
||
[make_finish_chunk()],
|
||
[make_finish_chunk()],
|
||
[make_finish_chunk()],
|
||
]
|
||
fake, original = install_fake_model(responses)
|
||
try:
|
||
result = manager.create_sub_agent(
|
||
agent_id=4,
|
||
summary="终止测试",
|
||
task="测试终止功能",
|
||
deliverables_dir="deliverables4",
|
||
timeout_seconds=30,
|
||
conversation_id="conv-test-4",
|
||
thinking_mode="fast",
|
||
run_in_background=True,
|
||
)
|
||
assert result["success"], result
|
||
time.sleep(0.5)
|
||
term_result = manager.terminate_sub_agent(task_id=result["task_id"])
|
||
assert term_result["success"], term_result
|
||
# 通过 task_id 查询最终状态
|
||
task = manager.lookup_task(task_id=result["task_id"])
|
||
assert task is not None
|
||
assert task["status"] == "terminated"
|
||
finally:
|
||
restore_model(original)
|
||
finally:
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
|
||
def run_all():
|
||
print("=" * 60)
|
||
print("SubAgent Python rewrite regression tests")
|
||
print("=" * 60)
|
||
tests = [
|
||
("write_file + edit_file", test_write_file_and_edit),
|
||
("read_mediafile", test_read_mediafile),
|
||
("terminate", test_terminate),
|
||
]
|
||
passed = 0
|
||
failed = 0
|
||
for name, fn in tests:
|
||
print(f"\nRunning: {name} ...")
|
||
try:
|
||
fn()
|
||
print(f" ✅ PASSED: {name}")
|
||
passed += 1
|
||
except Exception as exc:
|
||
print(f" ❌ FAILED: {name} -> {exc}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
failed += 1
|
||
print("\n" + "=" * 60)
|
||
print(f"RESULT: {passed} passed, {failed} failed")
|
||
print("=" * 60)
|
||
return failed == 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
ok = run_all()
|
||
sys.exit(0 if ok else 1)
|