You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
328 lines
12 KiB
328 lines
12 KiB
from threading import Event, Lock, Timer
|
|
|
|
from mulit_agent_app.application.task_executor import TaskExecutor
|
|
from mulit_agent_app.domain.attachments import TaskAttachment
|
|
from mulit_agent_app.infrastructure.database import AgentDatabase
|
|
|
|
|
|
def test_executor_reports_a_single_completed_lifecycle(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
executor = TaskExecutor(database)
|
|
events: list[dict[str, object]] = []
|
|
completed = Event()
|
|
|
|
def capture(event: object) -> None:
|
|
assert isinstance(event, dict)
|
|
events.append(event)
|
|
if event.get("status") == "completed":
|
|
completed.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("task-1", "确认 Agent 回传结果")
|
|
|
|
assert completed.wait(3)
|
|
assert [event["status"] for event in events] == ["accepted", "running", "completed"]
|
|
assert database.task_status("task-1") == "completed"
|
|
|
|
|
|
def test_executor_cancels_and_times_out_active_tasks(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
executor = TaskExecutor(database)
|
|
events: list[dict[str, object]] = []
|
|
terminal_events = Event()
|
|
|
|
def capture(event: object) -> None:
|
|
assert isinstance(event, dict)
|
|
events.append(event)
|
|
if len([item for item in events if item.get("status") in {"cancelled", "timed_out"}]) == 2:
|
|
terminal_events.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("cancel-task", "应被取消")
|
|
Timer(0.1, lambda: executor.cancel("cancel-task", "测试取消")).start()
|
|
executor.submit("timeout-task", "应超时", "2000-01-01T00:00:00+00:00")
|
|
|
|
assert terminal_events.wait(1)
|
|
assert database.task_status("cancel-task") == "cancelled"
|
|
assert database.task_status("timeout-task") == "timed_out"
|
|
assert [event["status"] for event in events if event["task_id"] == "cancel-task"][-1] == "cancelled"
|
|
assert [event["status"] for event in events if event["task_id"] == "timeout-task"][-1] == "timed_out"
|
|
|
|
|
|
def test_executor_uses_the_configured_model_runner(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
executor = TaskExecutor(database, lambda instruction: f"真实模型输出:{instruction}")
|
|
completed = Event()
|
|
events: list[dict[str, object]] = []
|
|
|
|
def capture(event: object) -> None:
|
|
assert isinstance(event, dict)
|
|
events.append(event)
|
|
if event.get("status") == "completed":
|
|
completed.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("model-task", "给出一句总结")
|
|
|
|
assert completed.wait(2)
|
|
assert events[-1]["output"] == "真实模型输出:给出一句总结"
|
|
assert events[-1]["execution"] == "llm"
|
|
|
|
|
|
def test_executor_keeps_each_controller_task_independent(tmp_path):
|
|
received: list[str] = []
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
executor = TaskExecutor(database, lambda instruction: received.append(instruction) or "任务结果")
|
|
completed = Event()
|
|
executor.task_event.connect(
|
|
lambda event: completed.set()
|
|
if isinstance(event, dict) and event.get("status") == "completed"
|
|
else None
|
|
)
|
|
|
|
executor.submit("design-direct-task", "整理蓝色方案")
|
|
|
|
assert completed.wait(2)
|
|
assert received == ["整理蓝色方案"]
|
|
assert database.task_status("design-direct-task") == "completed"
|
|
|
|
|
|
def test_executor_runs_independent_tasks_concurrently(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
both_started = Event()
|
|
release = Event()
|
|
completed = Event()
|
|
lock = Lock()
|
|
started = 0
|
|
terminal = 0
|
|
|
|
def runner(instruction: str) -> str:
|
|
nonlocal started
|
|
with lock:
|
|
started += 1
|
|
if started == 2:
|
|
both_started.set()
|
|
assert release.wait(2)
|
|
return f"完成:{instruction}"
|
|
|
|
executor = TaskExecutor(database, runner)
|
|
|
|
def capture(event: object) -> None:
|
|
nonlocal terminal
|
|
if isinstance(event, dict) and event.get("status") == "completed":
|
|
with lock:
|
|
terminal += 1
|
|
if terminal == 2:
|
|
completed.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("parallel-one", "第一条")
|
|
executor.submit("parallel-two", "第二条")
|
|
|
|
assert both_started.wait(1), "两个独立任务应同时进入模型执行"
|
|
release.set()
|
|
assert completed.wait(2)
|
|
assert database.task_status("parallel-one") == "completed"
|
|
assert database.task_status("parallel-two") == "completed"
|
|
|
|
|
|
def test_executor_marks_desktop_filesystem_execution(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
executor = TaskExecutor(database)
|
|
executor.set_full_access_enabled(False)
|
|
executor.set_desktop_filesystem_enabled(True)
|
|
executor._desktop_tool._desktop_path = tmp_path / "desktop"
|
|
completed = Event()
|
|
events: list[dict[str, object]] = []
|
|
|
|
def capture(event: object) -> None:
|
|
assert isinstance(event, dict)
|
|
events.append(event)
|
|
if event.get("status") == "completed":
|
|
completed.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("tool-task", "在电脑桌面新建 test 文件夹")
|
|
|
|
assert completed.wait(2)
|
|
assert events[-1]["execution"] == "desktop-filesystem"
|
|
|
|
|
|
def test_executor_defaults_to_full_access_tool_runner(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
calls: list[str] = []
|
|
|
|
def runner(_instruction, _attachments, run_local):
|
|
calls.append(run_local("Write-Output full-access-task"))
|
|
return "已根据真实本机命令完成。"
|
|
|
|
executor = TaskExecutor(database, runner)
|
|
completed = Event()
|
|
events: list[dict[str, object]] = []
|
|
|
|
def capture(event: object) -> None:
|
|
assert isinstance(event, dict)
|
|
events.append(event)
|
|
if event.get("status") == "completed":
|
|
completed.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("full-access-task", "在本机执行一项操作")
|
|
|
|
assert completed.wait(5)
|
|
assert calls == ["full-access-task"]
|
|
assert events[-1]["execution"] == "full-access"
|
|
|
|
|
|
def test_executor_rejects_prose_only_answer_for_a_local_action(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
executor = TaskExecutor(database, lambda _instruction: "我会在桌面创建文件夹。")
|
|
failed = Event()
|
|
events: list[dict[str, object]] = []
|
|
|
|
def capture(event: object) -> None:
|
|
assert isinstance(event, dict)
|
|
events.append(event)
|
|
if event.get("status") == "failed":
|
|
failed.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("no-tool-action", "在桌面创建一个 test 文件夹")
|
|
|
|
assert failed.wait(3)
|
|
assert "未执行任何操作" in str(events[-1]["error"])
|
|
|
|
|
|
def test_executor_does_not_fall_back_to_llm_when_desktop_tool_is_not_applied(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
calls: list[str] = []
|
|
executor = TaskExecutor(database, lambda instruction: calls.append(instruction) or "不应调用模型")
|
|
executor.set_full_access_enabled(False)
|
|
completed = Event()
|
|
events: list[dict[str, object]] = []
|
|
|
|
def capture(event: object) -> None:
|
|
assert isinstance(event, dict)
|
|
events.append(event)
|
|
if event.get("status") == "failed":
|
|
completed.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("missing-tool", "在电脑桌面新建 missing-tool-test 文件夹")
|
|
|
|
assert completed.wait(2)
|
|
assert calls == []
|
|
assert events[-1]["execution"] == "desktop-filesystem"
|
|
assert "未部署或尚未应用" in str(events[-1]["error"])
|
|
|
|
|
|
def test_executor_streams_tool_progress_while_the_task_runs(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
|
|
def runner(_instruction, _attachments, toolbox):
|
|
return f"结果:{toolbox.call('computer_time', {'timezone': 'UTC'})}"
|
|
|
|
executor = TaskExecutor(database, runner)
|
|
completed = Event()
|
|
events: list[dict[str, object]] = []
|
|
|
|
def capture(event: object) -> None:
|
|
assert isinstance(event, dict)
|
|
events.append(event)
|
|
if event.get("status") == "completed":
|
|
completed.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("progress-task", "看看现在几点")
|
|
|
|
assert completed.wait(10)
|
|
progress = [str(event["message"]) for event in events if event.get("status") == "progress"]
|
|
assert any(message.startswith("调用 computer_time") for message in progress), progress
|
|
assert any(message.startswith("computer_time 返回:") for message in progress), progress
|
|
# Progress must arrive before the terminal event so the UI can stream it live.
|
|
statuses = [event.get("status") for event in events]
|
|
assert statuses.index("progress") < statuses.index("completed")
|
|
|
|
|
|
def test_executor_ignores_progress_after_the_task_ended(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
executor = TaskExecutor(database, lambda instruction: "完成")
|
|
completed = Event()
|
|
events: list[dict[str, object]] = []
|
|
|
|
def capture(event: object) -> None:
|
|
assert isinstance(event, dict)
|
|
events.append(event)
|
|
if event.get("status") == "completed":
|
|
completed.set()
|
|
|
|
executor.task_event.connect(capture)
|
|
executor.submit("finished-task", "总结")
|
|
assert completed.wait(5)
|
|
reported = len(events)
|
|
|
|
executor._report_progress("finished-task")("迟到的进展")
|
|
|
|
assert len(events) == reported, "结束后的任务不应再产生中间步骤"
|
|
|
|
|
|
def test_late_model_result_cannot_overwrite_a_cancelled_terminal_state(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
started, release, returned, cancelled = Event(), Event(), Event(), Event()
|
|
|
|
def runner(_instruction: str) -> str:
|
|
started.set()
|
|
assert release.wait(2)
|
|
returned.set()
|
|
return "这是取消后才返回的模型结果"
|
|
|
|
executor = TaskExecutor(database, runner)
|
|
executor.task_event.connect(
|
|
lambda event: cancelled.set()
|
|
if isinstance(event, dict) and event.get("status") == "cancelled"
|
|
else None
|
|
)
|
|
executor.submit("late-result", "等待模型返回")
|
|
assert started.wait(2)
|
|
executor.cancel("late-result", "测试取消")
|
|
assert cancelled.wait(1)
|
|
release.set()
|
|
assert returned.wait(1)
|
|
assert database.task_status("late-result") == "cancelled"
|
|
|
|
|
|
def test_executor_passes_the_conversation_history_to_the_provider(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
received: list[tuple[tuple[str, str], ...]] = []
|
|
|
|
def runner(_instruction, _attachments, _toolbox, history):
|
|
received.append(history)
|
|
return "已复制到 D:\\target"
|
|
|
|
executor = TaskExecutor(database, runner)
|
|
completed = Event()
|
|
executor.task_event.connect(
|
|
lambda event: completed.set() if isinstance(event, dict) and event.get("status") == "completed" else None
|
|
)
|
|
|
|
executor.submit(
|
|
"follow-up-task",
|
|
"把它复制到 D:\\target",
|
|
history=(("user", "下载到 D:\\demo"), ("assistant", "已下载到 D:\\demo\\a.zip")),
|
|
)
|
|
|
|
assert completed.wait(5)
|
|
assert received == [(("user", "下载到 D:\\demo"), ("assistant", "已下载到 D:\\demo\\a.zip"))]
|
|
|
|
|
|
def test_image_task_never_falls_back_to_text_only_model(tmp_path):
|
|
database = AgentDatabase(tmp_path / "agent.sqlite3")
|
|
calls: list[str] = []
|
|
executor = TaskExecutor(database, lambda instruction: calls.append(instruction) or "不应调用")
|
|
finished = Event()
|
|
events: list[dict[str, object]] = []
|
|
executor.task_event.connect(lambda event: (events.append(event), finished.set()) if isinstance(event, dict) and event.get("status") == "failed" else None)
|
|
executor.submit("image-without-vision", "分析图片", attachments=(TaskAttachment("x.png", "image/png", "data:image/png;base64,aQ=="),))
|
|
assert finished.wait(2)
|
|
assert calls == []
|
|
assert "未被降级" in str(events[-1]["error"])
|
|
|