mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
- Add 42 tests for ContextBuilder (context.py: 0→42 tests) - Add 37 tests for SubagentManager lifecycle (subagent.py: 2→37 tests) - Add 42 unit tests for AutoCompact in isolation - Split monolithic test_runner.py (3313 lines) into 9 focused files: test_runner_core, test_runner_hooks, test_runner_errors, test_runner_safety, test_runner_persistence, test_runner_governance, test_runner_tool_execution, test_runner_injections, test_loop_runner_integration - Add 3 config passthrough tests (temperature/max_tokens/reasoning_effort) - Fix fragile patch.object(__init__) in test_stop_preserves_context - Create shared conftest.py with make_provider/make_loop factories Total: 934 tests passing, 0 regressions
559 lines
20 KiB
Python
559 lines
20 KiB
Python
"""Tests for SubagentManager lifecycle — spawn, run, announce, cancel."""
|
|
|
|
import asyncio
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from nanobot.agent.hook import AgentHookContext
|
|
from nanobot.agent.runner import AgentRunResult
|
|
from nanobot.agent.subagent import (
|
|
SubagentManager,
|
|
SubagentStatus,
|
|
_SubagentHook,
|
|
)
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.providers.base import LLMProvider
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _manager(tmp_path: Path, **kw) -> SubagentManager:
|
|
provider = MagicMock(spec=LLMProvider)
|
|
provider.get_default_model.return_value = "test-model"
|
|
defaults = dict(
|
|
provider=provider,
|
|
workspace=tmp_path,
|
|
bus=MessageBus(),
|
|
model="test-model",
|
|
max_tool_result_chars=16_000,
|
|
)
|
|
defaults.update(kw)
|
|
return SubagentManager(**defaults)
|
|
|
|
|
|
def _make_hook_context(**overrides) -> AgentHookContext:
|
|
defaults = dict(
|
|
iteration=1,
|
|
tool_calls=[],
|
|
tool_events=[],
|
|
messages=[],
|
|
usage={},
|
|
error=None,
|
|
stop_reason="completed",
|
|
final_content="ok",
|
|
)
|
|
defaults.update(overrides)
|
|
return AgentHookContext(**defaults)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SubagentStatus defaults
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSubagentStatus:
|
|
def test_defaults(self):
|
|
s = SubagentStatus(
|
|
task_id="abc", label="test", task_description="do stuff",
|
|
started_at=time.monotonic(),
|
|
)
|
|
assert s.phase == "initializing"
|
|
assert s.iteration == 0
|
|
assert s.tool_events == []
|
|
assert s.usage == {}
|
|
assert s.stop_reason is None
|
|
assert s.error is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# set_provider
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSetProvider:
|
|
def test_updates_provider_model_runner(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
new_provider = MagicMock(spec=LLMProvider)
|
|
sm.set_provider(new_provider, "new-model")
|
|
assert sm.provider is new_provider
|
|
assert sm.model == "new-model"
|
|
assert sm.runner.provider is new_provider
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# spawn
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSpawn:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_string_with_task_id(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
|
final_content="done", messages=[], stop_reason="completed",
|
|
))
|
|
result = await sm.spawn("do something")
|
|
assert "started" in result
|
|
assert "id:" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_creates_task_in_running_tasks(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
block = asyncio.Event()
|
|
async def _slow_run(spec):
|
|
await block.wait()
|
|
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
|
sm.runner.run = _slow_run
|
|
|
|
await sm.spawn("task", session_key="s1")
|
|
assert len(sm._running_tasks) == 1
|
|
|
|
block.set()
|
|
await asyncio.sleep(0.1)
|
|
assert len(sm._running_tasks) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_creates_status(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
|
final_content="done", messages=[], stop_reason="completed",
|
|
))
|
|
await sm.spawn("my task")
|
|
await asyncio.sleep(0.1)
|
|
# Status cleaned up after task completes
|
|
assert len(sm._task_statuses) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registers_in_session_tasks(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
block = asyncio.Event()
|
|
async def _slow_run(spec):
|
|
await block.wait()
|
|
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
|
sm.runner.run = _slow_run
|
|
|
|
await sm.spawn("task", session_key="s1")
|
|
assert "s1" in sm._session_tasks
|
|
assert len(sm._session_tasks["s1"]) == 1
|
|
|
|
block.set()
|
|
await asyncio.sleep(0.1)
|
|
assert "s1" not in sm._session_tasks
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_session_key_no_registration(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
block = asyncio.Event()
|
|
async def _slow_run(spec):
|
|
await block.wait()
|
|
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
|
sm.runner.run = _slow_run
|
|
|
|
await sm.spawn("task")
|
|
assert len(sm._session_tasks) == 0
|
|
|
|
block.set()
|
|
await asyncio.sleep(0.1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_label_defaults_to_truncated_task(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
block = asyncio.Event()
|
|
async def _slow_run(spec):
|
|
await block.wait()
|
|
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
|
sm.runner.run = _slow_run
|
|
|
|
long_task = "A" * 50
|
|
await sm.spawn(long_task, session_key="s1")
|
|
status = next(iter(sm._task_statuses.values()))
|
|
assert status.label == long_task[:30] + "..."
|
|
|
|
block.set()
|
|
await asyncio.sleep(0.1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_label(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
block = asyncio.Event()
|
|
async def _slow_run(spec):
|
|
await block.wait()
|
|
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
|
sm.runner.run = _slow_run
|
|
|
|
await sm.spawn("task", label="Custom Label", session_key="s1")
|
|
status = next(iter(sm._task_statuses.values()))
|
|
assert status.label == "Custom Label"
|
|
|
|
block.set()
|
|
await asyncio.sleep(0.1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_callback_removes_all_entries(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
|
final_content="done", messages=[], stop_reason="completed",
|
|
))
|
|
await sm.spawn("task", session_key="s1")
|
|
await asyncio.sleep(0.1)
|
|
assert len(sm._running_tasks) == 0
|
|
assert len(sm._task_statuses) == 0
|
|
assert len(sm._session_tasks) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _run_subagent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRunSubagent:
|
|
@pytest.mark.asyncio
|
|
async def test_successful_run(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
|
final_content="Task done!", messages=[], stop_reason="completed",
|
|
))
|
|
with patch.object(sm, "_announce_result", new_callable=AsyncMock) as mock_announce:
|
|
await sm._run_subagent(
|
|
"t1", "do task", "label",
|
|
{"channel": "cli", "chat_id": "direct"},
|
|
SubagentStatus(task_id="t1", label="label", task_description="do task", started_at=time.monotonic()),
|
|
)
|
|
mock_announce.assert_called_once()
|
|
assert mock_announce.call_args.args[-2] == "ok"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_error_run(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
|
final_content=None, messages=[], stop_reason="tool_error",
|
|
tool_events=[{"name": "read_file", "status": "error", "detail": "not found"}],
|
|
))
|
|
status = SubagentStatus(task_id="t1", label="label", task_description="do task", started_at=time.monotonic())
|
|
with patch.object(sm, "_announce_result", new_callable=AsyncMock) as mock_announce:
|
|
await sm._run_subagent(
|
|
"t1", "do task", "label",
|
|
{"channel": "cli", "chat_id": "direct"}, status,
|
|
)
|
|
assert mock_announce.call_args.args[-2] == "error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exception_run(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
sm.runner.run = AsyncMock(side_effect=RuntimeError("LLM down"))
|
|
status = SubagentStatus(task_id="t1", label="label", task_description="do task", started_at=time.monotonic())
|
|
with patch.object(sm, "_announce_result", new_callable=AsyncMock) as mock_announce:
|
|
await sm._run_subagent(
|
|
"t1", "do task", "label",
|
|
{"channel": "cli", "chat_id": "direct"}, status,
|
|
)
|
|
assert status.phase == "error"
|
|
assert "LLM down" in status.error
|
|
assert mock_announce.call_args.args[-2] == "error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_updated_on_success(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
|
final_content="ok", messages=[], stop_reason="completed",
|
|
))
|
|
status = SubagentStatus(task_id="t1", label="label", task_description="do task", started_at=time.monotonic())
|
|
with patch.object(sm, "_announce_result", new_callable=AsyncMock):
|
|
await sm._run_subagent(
|
|
"t1", "do task", "label",
|
|
{"channel": "cli", "chat_id": "direct"}, status,
|
|
)
|
|
assert status.phase == "done"
|
|
assert status.stop_reason == "completed"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _announce_result
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAnnounceResult:
|
|
@pytest.mark.asyncio
|
|
async def test_publishes_inbound_message(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
published = []
|
|
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
|
|
|
await sm._announce_result(
|
|
"t1", "label", "task", "result text",
|
|
{"channel": "cli", "chat_id": "direct"}, "ok",
|
|
)
|
|
|
|
assert len(published) == 1
|
|
msg = published[0]
|
|
assert msg.channel == "system"
|
|
assert msg.sender_id == "subagent"
|
|
assert msg.metadata["injected_event"] == "subagent_result"
|
|
assert msg.metadata["subagent_task_id"] == "t1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_key_override(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
published = []
|
|
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
|
|
|
await sm._announce_result(
|
|
"t1", "label", "task", "result",
|
|
{"channel": "telegram", "chat_id": "123", "session_key": "s1"}, "ok",
|
|
)
|
|
|
|
assert published[0].session_key_override == "s1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_key_override_fallback(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
published = []
|
|
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
|
|
|
await sm._announce_result(
|
|
"t1", "label", "task", "result",
|
|
{"channel": "telegram", "chat_id": "123"}, "ok",
|
|
)
|
|
|
|
assert published[0].session_key_override == "telegram:123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ok_status_text(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
published = []
|
|
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
|
|
|
await sm._announce_result(
|
|
"t1", "label", "task", "result",
|
|
{"channel": "cli", "chat_id": "direct"}, "ok",
|
|
)
|
|
|
|
assert "completed successfully" in published[0].content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_status_text(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
published = []
|
|
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
|
|
|
await sm._announce_result(
|
|
"t1", "label", "task", "error details",
|
|
{"channel": "cli", "chat_id": "direct"}, "error",
|
|
)
|
|
|
|
assert "failed" in published[0].content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_origin_message_id_in_metadata(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
published = []
|
|
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
|
|
|
await sm._announce_result(
|
|
"t1", "label", "task", "result",
|
|
{"channel": "cli", "chat_id": "direct"}, "ok",
|
|
origin_message_id="msg-123",
|
|
)
|
|
|
|
assert published[0].metadata["origin_message_id"] == "msg-123"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _format_partial_progress
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFormatPartialProgress:
|
|
def _make_result(self, tool_events=None, error=None):
|
|
return MagicMock(tool_events=tool_events or [], error=error)
|
|
|
|
def test_completed_only(self):
|
|
result = self._make_result(tool_events=[
|
|
{"name": "read_file", "status": "ok", "detail": "file content"},
|
|
{"name": "exec", "status": "ok", "detail": "output"},
|
|
])
|
|
text = SubagentManager._format_partial_progress(result)
|
|
assert "Completed steps:" in text
|
|
assert "read_file" in text
|
|
assert "exec" in text
|
|
|
|
def test_failure_only(self):
|
|
result = self._make_result(tool_events=[
|
|
{"name": "read_file", "status": "error", "detail": "not found"},
|
|
])
|
|
text = SubagentManager._format_partial_progress(result)
|
|
assert "Failure:" in text
|
|
assert "not found" in text
|
|
|
|
def test_completed_and_failure(self):
|
|
result = self._make_result(tool_events=[
|
|
{"name": "read_file", "status": "ok", "detail": "content"},
|
|
{"name": "exec", "status": "error", "detail": "timeout"},
|
|
])
|
|
text = SubagentManager._format_partial_progress(result)
|
|
assert "Completed steps:" in text
|
|
assert "Failure:" in text
|
|
|
|
def test_limited_to_last_three(self):
|
|
result = self._make_result(tool_events=[
|
|
{"name": f"tool_{i}", "status": "ok", "detail": f"result_{i}"}
|
|
for i in range(5)
|
|
])
|
|
text = SubagentManager._format_partial_progress(result)
|
|
assert "tool_2" in text
|
|
assert "tool_3" in text
|
|
assert "tool_4" in text
|
|
assert "tool_0" not in text
|
|
assert "tool_1" not in text
|
|
|
|
def test_error_without_failure_event(self):
|
|
result = self._make_result(
|
|
tool_events=[{"name": "read_file", "status": "ok", "detail": "ok"}],
|
|
error="Something went wrong",
|
|
)
|
|
text = SubagentManager._format_partial_progress(result)
|
|
assert "Something went wrong" in text
|
|
|
|
def test_empty_events_with_error(self):
|
|
result = self._make_result(error="Total failure")
|
|
text = SubagentManager._format_partial_progress(result)
|
|
assert "Total failure" in text
|
|
|
|
def test_empty_no_error_returns_fallback(self):
|
|
result = self._make_result()
|
|
text = SubagentManager._format_partial_progress(result)
|
|
assert "Error" in text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cancel_by_session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCancelBySession:
|
|
@pytest.mark.asyncio
|
|
async def test_cancels_running_tasks(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
block = asyncio.Event()
|
|
async def _slow_run(spec):
|
|
await block.wait()
|
|
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
|
sm.runner.run = _slow_run
|
|
|
|
await sm.spawn("task1", session_key="s1")
|
|
await sm.spawn("task2", session_key="s1")
|
|
assert len(sm._session_tasks.get("s1", set())) == 2
|
|
|
|
count = await sm.cancel_by_session("s1")
|
|
assert count == 2
|
|
block.set()
|
|
await asyncio.sleep(0.1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_tasks_returns_zero(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
count = await sm.cancel_by_session("nonexistent")
|
|
assert count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_already_done_not_counted(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
|
final_content="done", messages=[], stop_reason="completed",
|
|
))
|
|
await sm.spawn("task1", session_key="s1")
|
|
await asyncio.sleep(0.1) # Wait for completion
|
|
|
|
count = await sm.cancel_by_session("s1")
|
|
assert count == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_running_count / get_running_count_by_session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRunningCounts:
|
|
@pytest.mark.asyncio
|
|
async def test_running_count_zero(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
assert sm.get_running_count() == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_running_count_tracks_tasks(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
block = asyncio.Event()
|
|
async def _slow_run(spec):
|
|
await block.wait()
|
|
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
|
sm.runner.run = _slow_run
|
|
|
|
await sm.spawn("t1", session_key="s1")
|
|
await sm.spawn("t2", session_key="s1")
|
|
assert sm.get_running_count() == 2
|
|
assert sm.get_running_count_by_session("s1") == 2
|
|
|
|
block.set()
|
|
await asyncio.sleep(0.1)
|
|
assert sm.get_running_count() == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_running_count_by_session_nonexistent(self, tmp_path):
|
|
sm = _manager(tmp_path)
|
|
assert sm.get_running_count_by_session("nonexistent") == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _SubagentHook
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSubagentHook:
|
|
@pytest.mark.asyncio
|
|
async def test_before_execute_tools_logs(self, tmp_path):
|
|
hook = _SubagentHook("t1")
|
|
tool_call = MagicMock()
|
|
tool_call.name = "read_file"
|
|
tool_call.arguments = {"path": "/tmp/test"}
|
|
ctx = _make_hook_context(tool_calls=[tool_call])
|
|
# Should not raise
|
|
await hook.before_execute_tools(ctx)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_iteration_updates_status(self):
|
|
status = SubagentStatus(
|
|
task_id="t1", label="test", task_description="do", started_at=time.monotonic(),
|
|
)
|
|
hook = _SubagentHook("t1", status)
|
|
ctx = _make_hook_context(
|
|
iteration=3,
|
|
tool_events=[{"name": "read_file", "status": "ok", "detail": ""}],
|
|
usage={"prompt_tokens": 100},
|
|
)
|
|
await hook.after_iteration(ctx)
|
|
assert status.iteration == 3
|
|
assert len(status.tool_events) == 1
|
|
assert status.usage == {"prompt_tokens": 100}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_iteration_no_status_noop(self):
|
|
hook = _SubagentHook("t1", status=None)
|
|
ctx = _make_hook_context(iteration=5)
|
|
# Should not raise
|
|
await hook.after_iteration(ctx)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_iteration_sets_error(self):
|
|
status = SubagentStatus(
|
|
task_id="t1", label="test", task_description="do", started_at=time.monotonic(),
|
|
)
|
|
hook = _SubagentHook("t1", status)
|
|
ctx = _make_hook_context(error="something broke")
|
|
await hook.after_iteration(ctx)
|
|
assert status.error == "something broke"
|