mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-30 06:45:55 +00:00
The previous fix hardcoded session_key_override as channel:chat_id which broke unified session mode where pending queues use "unified:default". Propagate the effective key from _set_tool_context through SpawnTool into the origin dict so _announce_result routes to the correct pending queue in both normal and unified session modes.
503 lines
18 KiB
Python
503 lines
18 KiB
Python
"""Tests for /stop task cancellation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from nanobot.config.schema import AgentDefaults
|
|
|
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
|
|
|
|
|
def _make_loop(*, exec_config=None):
|
|
"""Create a minimal AgentLoop with mocked dependencies."""
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
workspace = MagicMock()
|
|
workspace.__truediv__ = MagicMock(return_value=MagicMock())
|
|
|
|
with patch("nanobot.agent.loop.ContextBuilder"), \
|
|
patch("nanobot.agent.loop.SessionManager"), \
|
|
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
|
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
|
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace, exec_config=exec_config)
|
|
return loop, bus
|
|
|
|
|
|
class TestHandleStop:
|
|
@pytest.mark.asyncio
|
|
async def test_stop_no_active_task(self):
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.command.builtin import cmd_stop
|
|
from nanobot.command.router import CommandContext
|
|
|
|
loop, bus = _make_loop()
|
|
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
|
|
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
|
|
out = await cmd_stop(ctx)
|
|
assert "No active task" in out.content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_cancels_active_task(self):
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.command.builtin import cmd_stop
|
|
from nanobot.command.router import CommandContext
|
|
|
|
loop, bus = _make_loop()
|
|
cancelled = asyncio.Event()
|
|
|
|
async def slow_task():
|
|
try:
|
|
await asyncio.sleep(60)
|
|
except asyncio.CancelledError:
|
|
cancelled.set()
|
|
raise
|
|
|
|
task = asyncio.create_task(slow_task())
|
|
await asyncio.sleep(0)
|
|
loop._active_tasks["test:c1"] = [task]
|
|
|
|
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
|
|
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
|
|
out = await cmd_stop(ctx)
|
|
|
|
assert cancelled.is_set()
|
|
assert "stopped" in out.content.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_cancels_multiple_tasks(self):
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.command.builtin import cmd_stop
|
|
from nanobot.command.router import CommandContext
|
|
|
|
loop, bus = _make_loop()
|
|
events = [asyncio.Event(), asyncio.Event()]
|
|
|
|
async def slow(idx):
|
|
try:
|
|
await asyncio.sleep(60)
|
|
except asyncio.CancelledError:
|
|
events[idx].set()
|
|
raise
|
|
|
|
tasks = [asyncio.create_task(slow(i)) for i in range(2)]
|
|
await asyncio.sleep(0)
|
|
loop._active_tasks["test:c1"] = tasks
|
|
|
|
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
|
|
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
|
|
out = await cmd_stop(ctx)
|
|
|
|
assert all(e.is_set() for e in events)
|
|
assert "2 task" in out.content
|
|
|
|
|
|
class TestDispatch:
|
|
def test_exec_tool_not_registered_when_disabled(self):
|
|
from nanobot.config.schema import ExecToolConfig
|
|
|
|
loop, _bus = _make_loop(exec_config=ExecToolConfig(enable=False))
|
|
|
|
assert loop.tools.get("exec") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_processes_and_publishes(self):
|
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
|
|
|
loop, bus = _make_loop()
|
|
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="hello")
|
|
loop._process_message = AsyncMock(
|
|
return_value=OutboundMessage(channel="test", chat_id="c1", content="hi")
|
|
)
|
|
await loop._dispatch(msg)
|
|
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
|
assert out.content == "hi"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_streaming_preserves_message_metadata(self):
|
|
from nanobot.bus.events import InboundMessage
|
|
|
|
loop, bus = _make_loop()
|
|
msg = InboundMessage(
|
|
channel="matrix",
|
|
sender_id="u1",
|
|
chat_id="!room:matrix.org",
|
|
content="hello",
|
|
metadata={
|
|
"_wants_stream": True,
|
|
"thread_root_event_id": "$root1",
|
|
"thread_reply_to_event_id": "$reply1",
|
|
},
|
|
)
|
|
|
|
async def fake_process(_msg, *, on_stream=None, on_stream_end=None, **kwargs):
|
|
assert on_stream is not None
|
|
assert on_stream_end is not None
|
|
await on_stream("hi")
|
|
await on_stream_end(resuming=False)
|
|
return None
|
|
|
|
loop._process_message = fake_process
|
|
|
|
await loop._dispatch(msg)
|
|
first = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
|
second = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
|
|
|
assert first.metadata["thread_root_event_id"] == "$root1"
|
|
assert first.metadata["thread_reply_to_event_id"] == "$reply1"
|
|
assert first.metadata["_stream_delta"] is True
|
|
assert second.metadata["thread_root_event_id"] == "$root1"
|
|
assert second.metadata["thread_reply_to_event_id"] == "$reply1"
|
|
assert second.metadata["_stream_end"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_processing_lock_serializes(self):
|
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
|
|
|
loop, bus = _make_loop()
|
|
order = []
|
|
|
|
async def mock_process(m, **kwargs):
|
|
order.append(f"start-{m.content}")
|
|
await asyncio.sleep(0.05)
|
|
order.append(f"end-{m.content}")
|
|
return OutboundMessage(channel="test", chat_id="c1", content=m.content)
|
|
|
|
loop._process_message = mock_process
|
|
msg1 = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="a")
|
|
msg2 = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="b")
|
|
|
|
t1 = asyncio.create_task(loop._dispatch(msg1))
|
|
t2 = asyncio.create_task(loop._dispatch(msg2))
|
|
await asyncio.gather(t1, t2)
|
|
assert order == ["start-a", "end-a", "start-b", "end-b"]
|
|
|
|
|
|
class TestSubagentCancellation:
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_by_session(self):
|
|
from nanobot.agent.subagent import SubagentManager
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
mgr = SubagentManager(
|
|
provider=provider,
|
|
workspace=MagicMock(),
|
|
bus=bus,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
)
|
|
|
|
cancelled = asyncio.Event()
|
|
|
|
async def slow():
|
|
try:
|
|
await asyncio.sleep(60)
|
|
except asyncio.CancelledError:
|
|
cancelled.set()
|
|
raise
|
|
|
|
task = asyncio.create_task(slow())
|
|
await asyncio.sleep(0)
|
|
mgr._running_tasks["sub-1"] = task
|
|
mgr._session_tasks["test:c1"] = {"sub-1"}
|
|
|
|
count = await mgr.cancel_by_session("test:c1")
|
|
assert count == 1
|
|
assert cancelled.is_set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_by_session_no_tasks(self):
|
|
from nanobot.agent.subagent import SubagentManager
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
mgr = SubagentManager(
|
|
provider=provider,
|
|
workspace=MagicMock(),
|
|
bus=bus,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
)
|
|
assert await mgr.cancel_by_session("nonexistent") == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subagent_preserves_reasoning_fields_in_tool_turn(self, monkeypatch, tmp_path):
|
|
from nanobot.agent.subagent import SubagentManager
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
|
|
captured_second_call: list[dict] = []
|
|
|
|
call_count = {"n": 0}
|
|
|
|
async def scripted_chat_with_retry(*, messages, **kwargs):
|
|
call_count["n"] += 1
|
|
if call_count["n"] == 1:
|
|
return LLMResponse(
|
|
content="thinking",
|
|
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
|
reasoning_content="hidden reasoning",
|
|
thinking_blocks=[{"type": "thinking", "thinking": "step"}],
|
|
)
|
|
captured_second_call[:] = messages
|
|
return LLMResponse(content="done", tool_calls=[])
|
|
provider.chat_with_retry = scripted_chat_with_retry
|
|
mgr = SubagentManager(
|
|
provider=provider,
|
|
workspace=tmp_path,
|
|
bus=bus,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
)
|
|
|
|
async def fake_execute(self, **kwargs):
|
|
return "tool result"
|
|
|
|
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
|
|
|
|
from nanobot.agent.subagent import SubagentStatus
|
|
status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic())
|
|
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status)
|
|
|
|
assistant_messages = [
|
|
msg for msg in captured_second_call
|
|
if msg.get("role") == "assistant" and msg.get("tool_calls")
|
|
]
|
|
assert len(assistant_messages) == 1
|
|
assert assistant_messages[0]["reasoning_content"] == "hidden reasoning"
|
|
assert assistant_messages[0]["thinking_blocks"] == [{"type": "thinking", "thinking": "step"}]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subagent_exec_tool_not_registered_when_disabled(self, tmp_path):
|
|
from nanobot.agent.subagent import SubagentManager
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.config.schema import ExecToolConfig
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
mgr = SubagentManager(
|
|
provider=provider,
|
|
workspace=tmp_path,
|
|
bus=bus,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
exec_config=ExecToolConfig(enable=False),
|
|
)
|
|
mgr._announce_result = AsyncMock()
|
|
|
|
async def fake_run(spec):
|
|
assert spec.tools.get("exec") is None
|
|
return SimpleNamespace(
|
|
stop_reason="done",
|
|
final_content="done",
|
|
error=None,
|
|
tool_events=[],
|
|
)
|
|
|
|
mgr.runner.run = AsyncMock(side_effect=fake_run)
|
|
|
|
from nanobot.agent.subagent import SubagentStatus
|
|
status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic())
|
|
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status)
|
|
|
|
mgr.runner.run.assert_awaited_once()
|
|
mgr._announce_result.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subagent_announces_error_when_tool_execution_fails(self, monkeypatch, tmp_path):
|
|
from nanobot.agent.subagent import SubagentManager
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
content="thinking",
|
|
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
|
))
|
|
mgr = SubagentManager(
|
|
provider=provider,
|
|
workspace=tmp_path,
|
|
bus=bus,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
)
|
|
mgr._announce_result = AsyncMock()
|
|
|
|
calls = {"n": 0}
|
|
|
|
async def fake_execute(self, **kwargs):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
return "first result"
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
|
|
|
|
from nanobot.agent.subagent import SubagentStatus
|
|
status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic())
|
|
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status)
|
|
|
|
mgr._announce_result.assert_awaited_once()
|
|
args = mgr._announce_result.await_args.args
|
|
assert "Completed steps:" in args[3]
|
|
assert "- list_dir: first result" in args[3]
|
|
assert "Failure:" in args[3]
|
|
assert "- list_dir: boom" in args[3]
|
|
assert args[5] == "error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_by_session_cancels_running_subagent_tool(self, monkeypatch, tmp_path):
|
|
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
content="thinking",
|
|
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
|
))
|
|
mgr = SubagentManager(
|
|
provider=provider,
|
|
workspace=tmp_path,
|
|
bus=bus,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
)
|
|
mgr._announce_result = AsyncMock()
|
|
|
|
started = asyncio.Event()
|
|
cancelled = asyncio.Event()
|
|
|
|
async def fake_execute(self, **kwargs):
|
|
started.set()
|
|
try:
|
|
await asyncio.sleep(60)
|
|
except asyncio.CancelledError:
|
|
cancelled.set()
|
|
raise
|
|
|
|
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
|
|
|
|
task = asyncio.create_task(
|
|
mgr._run_subagent(
|
|
"sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"},
|
|
SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic()),
|
|
)
|
|
)
|
|
mgr._running_tasks["sub-1"] = task
|
|
mgr._session_tasks["test:c1"] = {"sub-1"}
|
|
|
|
await asyncio.wait_for(started.wait(), timeout=1.0)
|
|
|
|
count = await mgr.cancel_by_session("test:c1")
|
|
|
|
assert count == 1
|
|
assert cancelled.is_set()
|
|
assert task.cancelled()
|
|
mgr._announce_result.assert_not_awaited()
|
|
|
|
|
|
class TestSubagentAnnounceSessionKey:
|
|
"""Verify _announce_result uses the effective session key for mid-turn routing."""
|
|
|
|
def _make_mgr(self):
|
|
"""Create a SubagentManager with mocked deps and its bus."""
|
|
from nanobot.agent.subagent import SubagentManager
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
bus = MessageBus()
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = "test-model"
|
|
mgr = SubagentManager(
|
|
provider=provider,
|
|
workspace=MagicMock(),
|
|
bus=bus,
|
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
)
|
|
return mgr, bus
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_announce_uses_effective_key_in_unified_mode(self):
|
|
"""In unified session mode, session_key_override must be 'unified:default'
|
|
so the result matches the pending queue key."""
|
|
mgr, bus = self._make_mgr()
|
|
|
|
origin = {"channel": "telegram", "chat_id": "111", "session_key": "unified:default"}
|
|
await mgr._announce_result("sub-1", "label", "task", "result", origin, "ok")
|
|
|
|
msg = await bus.consume_inbound()
|
|
assert msg.session_key_override == "unified:default"
|
|
assert msg.session_key == "unified:default"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_announce_uses_raw_key_in_normal_mode(self):
|
|
"""Without unified sessions, session_key_override is the raw channel:chat_id."""
|
|
mgr, bus = self._make_mgr()
|
|
|
|
origin = {"channel": "telegram", "chat_id": "222", "session_key": "telegram:222"}
|
|
await mgr._announce_result("sub-2", "label", "task", "result", origin, "ok")
|
|
|
|
msg = await bus.consume_inbound()
|
|
assert msg.session_key_override == "telegram:222"
|
|
assert msg.session_key == "telegram:222"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_announce_falls_back_to_origin_when_no_session_key(self):
|
|
"""When session_key is None, fallback to f'{channel}:{chat_id}'."""
|
|
mgr, bus = self._make_mgr()
|
|
|
|
origin = {"channel": "discord", "chat_id": "333", "session_key": None}
|
|
await mgr._announce_result("sub-3", "label", "task", "result", origin, "ok")
|
|
|
|
msg = await bus.consume_inbound()
|
|
assert msg.session_key_override == "discord:333"
|
|
assert msg.channel == "system"
|
|
assert msg.chat_id == "discord:333"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_key_flows_through_run_subagent(self):
|
|
"""Verify session_key in origin propagates from _run_subagent to _announce_result."""
|
|
from nanobot.agent.subagent import SubagentStatus
|
|
|
|
mgr, bus = self._make_mgr()
|
|
|
|
async def fake_run(spec):
|
|
return SimpleNamespace(
|
|
stop_reason="done",
|
|
final_content="done",
|
|
error=None,
|
|
tool_events=[],
|
|
)
|
|
|
|
mgr.runner.run = AsyncMock(side_effect=fake_run)
|
|
|
|
status = SubagentStatus(
|
|
task_id="sub-4", label="label", task_description="task",
|
|
started_at=time.monotonic(),
|
|
)
|
|
await mgr._run_subagent(
|
|
"sub-4", "task", "label",
|
|
{"channel": "telegram", "chat_id": "444", "session_key": "unified:default"},
|
|
status,
|
|
)
|
|
|
|
msg = await bus.consume_inbound()
|
|
assert msg.session_key_override == "unified:default"
|