refactor command routing for future plugins and clearer CLI structure

This commit is contained in:
Xubin Ren
2026-03-23 08:40:55 +00:00
committed by Xubin Ren
parent aba0b83a77
commit 20494a2c52
12 changed files with 256 additions and 127 deletions

View File

@@ -15,7 +15,7 @@ root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
printf " %-16s %5s lines\n" "(root)" "$root" printf " %-16s %5s lines\n" "(root)" "$root"
echo "" echo ""
total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l) total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/command/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l)
echo " Core total: $total lines" echo " Core total: $total lines"
echo "" echo ""
echo " (excludes: channels/, cli/, providers/, skills/)" echo " (excludes: channels/, cli/, command/, providers/, skills/)"

View File

@@ -4,9 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os
import re import re
import sys
import time import time
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from pathlib import Path from pathlib import Path
@@ -14,7 +12,6 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger from loguru import logger
from nanobot import __version__
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.memory import MemoryConsolidator from nanobot.agent.memory import MemoryConsolidator
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
@@ -27,7 +24,7 @@ from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.utils.helpers import build_status_content from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
@@ -118,6 +115,8 @@ class AgentLoop:
max_completion_tokens=provider.generation.max_tokens, max_completion_tokens=provider.generation.max_tokens,
) )
self._register_default_tools() self._register_default_tools()
self.commands = CommandRouter()
register_builtin_commands(self.commands)
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
"""Register the default set of tools.""" """Register the default set of tools."""
@@ -188,28 +187,6 @@ class AgentLoop:
return f'{tc.name}("{val[:40]}")' if len(val) > 40 else f'{tc.name}("{val}")' return f'{tc.name}("{val[:40]}")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls) return ", ".join(_fmt(tc) for tc in tool_calls)
def _status_response(self, msg: InboundMessage, session: Session) -> OutboundMessage:
"""Build an outbound status message for a session."""
ctx_est = 0
try:
ctx_est, _ = self.memory_consolidator.estimate_session_prompt_tokens(session)
except Exception:
pass
if ctx_est <= 0:
ctx_est = self._last_usage.get("prompt_tokens", 0)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=build_status_content(
version=__version__, model=self.model,
start_time=self._start_time, last_usage=self._last_usage,
context_window_tokens=self.context_window_tokens,
session_msg_count=len(session.get_history(max_messages=0)),
context_tokens_estimate=ctx_est,
),
metadata={"render_as": "text"},
)
async def _run_agent_loop( async def _run_agent_loop(
self, self,
initial_messages: list[dict], initial_messages: list[dict],
@@ -348,48 +325,16 @@ class AgentLoop:
logger.warning("Error consuming inbound message: {}, continuing...", e) logger.warning("Error consuming inbound message: {}, continuing...", e)
continue continue
cmd = msg.content.strip().lower() raw = msg.content.strip()
if cmd == "/stop": if self.commands.is_priority(raw):
await self._handle_stop(msg) ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, loop=self)
elif cmd == "/restart": result = await self.commands.dispatch_priority(ctx)
await self._handle_restart(msg) if result:
elif cmd == "/status": await self.bus.publish_outbound(result)
session = self.sessions.get_or_create(msg.session_key) continue
await self.bus.publish_outbound(self._status_response(msg, session)) task = asyncio.create_task(self._dispatch(msg))
else: self._active_tasks.setdefault(msg.session_key, []).append(task)
task = asyncio.create_task(self._dispatch(msg)) task.add_done_callback(lambda t, k=msg.session_key: self._active_tasks.get(k, []) and self._active_tasks[k].remove(t) if t in self._active_tasks.get(k, []) else None)
self._active_tasks.setdefault(msg.session_key, []).append(task)
task.add_done_callback(lambda t, k=msg.session_key: self._active_tasks.get(k, []) and self._active_tasks[k].remove(t) if t in self._active_tasks.get(k, []) else None)
async def _handle_stop(self, msg: InboundMessage) -> None:
"""Cancel all active tasks and subagents for the session."""
tasks = self._active_tasks.pop(msg.session_key, [])
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
sub_cancelled = await self.subagents.cancel_by_session(msg.session_key)
total = cancelled + sub_cancelled
content = f"Stopped {total} task(s)." if total else "No active task to stop."
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
))
async def _handle_restart(self, msg: InboundMessage) -> None:
"""Restart the process in-place via os.execv."""
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="Restarting...",
))
async def _do_restart():
await asyncio.sleep(1)
# Use -m nanobot instead of sys.argv[0] for Windows compatibility
# (sys.argv[0] may be just "nanobot" without full path on Windows)
os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:])
asyncio.create_task(_do_restart())
async def _dispatch(self, msg: InboundMessage) -> None: async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message under the global lock.""" """Process a message under the global lock."""
@@ -491,35 +436,11 @@ class AgentLoop:
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
# Slash commands # Slash commands
cmd = msg.content.strip().lower() raw = msg.content.strip()
if cmd == "/new": ctx = CommandContext(msg=msg, session=session, key=key, raw=raw, loop=self)
snapshot = session.messages[session.last_consolidated:] if result := await self.commands.dispatch(ctx):
session.clear() return result
self.sessions.save(session)
self.sessions.invalidate(session.key)
if snapshot:
self._schedule_background(self.memory_consolidator.archive_messages(snapshot))
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="New session started.")
if cmd == "/status":
return self._status_response(msg, session)
if cmd == "/help":
lines = [
"🐈 nanobot commands:",
"/new — Start a new conversation",
"/stop — Stop the current task",
"/restart — Restart the bot",
"/status — Show bot status",
"/help — Show available commands",
]
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="\n".join(lines),
metadata={"render_as": "text"},
)
await self.memory_consolidator.maybe_consolidate_by_tokens(session) await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))

View File

@@ -294,7 +294,7 @@ def onboard(
# Run interactive wizard if enabled # Run interactive wizard if enabled
if wizard: if wizard:
from nanobot.cli.onboard_wizard import run_onboard from nanobot.cli.onboard import run_onboard
try: try:
result = run_onboard(initial_config=config) result = run_onboard(initial_config=config)

View File

@@ -16,7 +16,7 @@ from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.table import Table from rich.table import Table
from nanobot.cli.model_info import ( from nanobot.cli.models import (
format_token_count, format_token_count,
get_model_context_limit, get_model_context_limit,
get_model_suggestions, get_model_suggestions,

View File

@@ -0,0 +1,6 @@
"""Slash command routing and built-in handlers."""
from nanobot.command.builtin import register_builtin_commands
from nanobot.command.router import CommandContext, CommandRouter
__all__ = ["CommandContext", "CommandRouter", "register_builtin_commands"]

110
nanobot/command/builtin.py Normal file
View File

@@ -0,0 +1,110 @@
"""Built-in slash command handlers."""
from __future__ import annotations
import asyncio
import os
import sys
from nanobot import __version__
from nanobot.bus.events import OutboundMessage
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.utils.helpers import build_status_content
async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session."""
loop = ctx.loop
msg = ctx.msg
tasks = loop._active_tasks.pop(msg.session_key, [])
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
sub_cancelled = await loop.subagents.cancel_by_session(msg.session_key)
total = cancelled + sub_cancelled
content = f"Stopped {total} task(s)." if total else "No active task to stop."
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content)
async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
"""Restart the process in-place via os.execv."""
msg = ctx.msg
async def _do_restart():
await asyncio.sleep(1)
os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:])
asyncio.create_task(_do_restart())
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="Restarting...")
async def cmd_status(ctx: CommandContext) -> OutboundMessage:
"""Build an outbound status message for a session."""
loop = ctx.loop
session = ctx.session or loop.sessions.get_or_create(ctx.key)
ctx_est = 0
try:
ctx_est, _ = loop.memory_consolidator.estimate_session_prompt_tokens(session)
except Exception:
pass
if ctx_est <= 0:
ctx_est = loop._last_usage.get("prompt_tokens", 0)
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content=build_status_content(
version=__version__, model=loop.model,
start_time=loop._start_time, last_usage=loop._last_usage,
context_window_tokens=loop.context_window_tokens,
session_msg_count=len(session.get_history(max_messages=0)),
context_tokens_estimate=ctx_est,
),
metadata={"render_as": "text"},
)
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
"""Start a fresh session."""
loop = ctx.loop
session = ctx.session or loop.sessions.get_or_create(ctx.key)
snapshot = session.messages[session.last_consolidated:]
session.clear()
loop.sessions.save(session)
loop.sessions.invalidate(session.key)
if snapshot:
loop._schedule_background(loop.memory_consolidator.archive_messages(snapshot))
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content="New session started.",
)
async def cmd_help(ctx: CommandContext) -> OutboundMessage:
"""Return available slash commands."""
lines = [
"🐈 nanobot commands:",
"/new — Start a new conversation",
"/stop — Stop the current task",
"/restart — Restart the bot",
"/status — Show bot status",
"/help — Show available commands",
]
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content="\n".join(lines),
metadata={"render_as": "text"},
)
def register_builtin_commands(router: CommandRouter) -> None:
"""Register the default set of slash commands."""
router.priority("/stop", cmd_stop)
router.priority("/restart", cmd_restart)
router.priority("/status", cmd_status)
router.exact("/new", cmd_new)
router.exact("/status", cmd_status)
router.exact("/help", cmd_help)

84
nanobot/command/router.py Normal file
View File

@@ -0,0 +1,84 @@
"""Minimal command routing table for slash commands."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Awaitable, Callable
if TYPE_CHECKING:
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.session.manager import Session
Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]]
@dataclass
class CommandContext:
"""Everything a command handler needs to produce a response."""
msg: InboundMessage
session: Session | None
key: str
raw: str
args: str = ""
loop: Any = None
class CommandRouter:
"""Pure dict-based command dispatch.
Three tiers checked in order:
1. *priority* — exact-match commands handled before the dispatch lock
(e.g. /stop, /restart).
2. *exact* — exact-match commands handled inside the dispatch lock.
3. *prefix* — longest-prefix-first match (e.g. "/team ").
4. *interceptors* — fallback predicates (e.g. team-mode active check).
"""
def __init__(self) -> None:
self._priority: dict[str, Handler] = {}
self._exact: dict[str, Handler] = {}
self._prefix: list[tuple[str, Handler]] = []
self._interceptors: list[Handler] = []
def priority(self, cmd: str, handler: Handler) -> None:
self._priority[cmd] = handler
def exact(self, cmd: str, handler: Handler) -> None:
self._exact[cmd] = handler
def prefix(self, pfx: str, handler: Handler) -> None:
self._prefix.append((pfx, handler))
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
def intercept(self, handler: Handler) -> None:
self._interceptors.append(handler)
def is_priority(self, text: str) -> bool:
return text.strip().lower() in self._priority
async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
"""Dispatch a priority command. Called from run() without the lock."""
handler = self._priority.get(ctx.raw.lower())
if handler:
return await handler(ctx)
return None
async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None:
"""Try exact, prefix, then interceptors. Returns None if unhandled."""
cmd = ctx.raw.lower()
if handler := self._exact.get(cmd):
return await handler(ctx)
for pfx, handler in self._prefix:
if cmd.startswith(pfx):
ctx.args = ctx.raw[len(pfx):]
return await handler(ctx)
for interceptor in self._interceptors:
result = await interceptor(ctx)
if result is not None:
return result
return None

View File

@@ -138,10 +138,10 @@ def test_onboard_help_shows_workspace_and_config_options():
def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch): def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch):
config_file, workspace_dir, _ = mock_paths config_file, workspace_dir, _ = mock_paths
from nanobot.cli.onboard_wizard import OnboardResult from nanobot.cli.onboard import OnboardResult
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.onboard_wizard.run_onboard", "nanobot.cli.onboard.run_onboard",
lambda initial_config: OnboardResult(config=initial_config, should_save=False), lambda initial_config: OnboardResult(config=initial_config, should_save=False),
) )
@@ -179,10 +179,10 @@ def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkey
config_path = tmp_path / "instance" / "config.json" config_path = tmp_path / "instance" / "config.json"
workspace_path = tmp_path / "workspace" workspace_path = tmp_path / "workspace"
from nanobot.cli.onboard_wizard import OnboardResult from nanobot.cli.onboard import OnboardResult
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.onboard_wizard.run_onboard", "nanobot.cli.onboard.run_onboard",
lambda initial_config: OnboardResult(config=initial_config, should_save=True), lambda initial_config: OnboardResult(config=initial_config, should_save=True),
) )
monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {}) monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {})

View File

@@ -12,11 +12,11 @@ from typing import Any, cast
import pytest import pytest
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from nanobot.cli import onboard_wizard from nanobot.cli import onboard as onboard_wizard
# Import functions to test # Import functions to test
from nanobot.cli.commands import _merge_missing_defaults from nanobot.cli.commands import _merge_missing_defaults
from nanobot.cli.onboard_wizard import ( from nanobot.cli.onboard import (
_BACK_PRESSED, _BACK_PRESSED,
_configure_pydantic_model, _configure_pydantic_model,
_format_value, _format_value,
@@ -352,7 +352,7 @@ class TestProviderChannelInfo:
"""Tests for provider and channel info retrieval.""" """Tests for provider and channel info retrieval."""
def test_get_provider_names_returns_dict(self): def test_get_provider_names_returns_dict(self):
from nanobot.cli.onboard_wizard import _get_provider_names from nanobot.cli.onboard import _get_provider_names
names = _get_provider_names() names = _get_provider_names()
assert isinstance(names, dict) assert isinstance(names, dict)
@@ -363,7 +363,7 @@ class TestProviderChannelInfo:
assert "github_copilot" not in names assert "github_copilot" not in names
def test_get_channel_names_returns_dict(self): def test_get_channel_names_returns_dict(self):
from nanobot.cli.onboard_wizard import _get_channel_names from nanobot.cli.onboard import _get_channel_names
names = _get_channel_names() names = _get_channel_names()
assert isinstance(names, dict) assert isinstance(names, dict)
@@ -371,7 +371,7 @@ class TestProviderChannelInfo:
assert len(names) >= 0 assert len(names) >= 0
def test_get_provider_info_returns_valid_structure(self): def test_get_provider_info_returns_valid_structure(self):
from nanobot.cli.onboard_wizard import _get_provider_info from nanobot.cli.onboard import _get_provider_info
info = _get_provider_info() info = _get_provider_info()
assert isinstance(info, dict) assert isinstance(info, dict)

View File

@@ -34,12 +34,15 @@ class TestRestartCommand:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_restart_sends_message_and_calls_execv(self): async def test_restart_sends_message_and_calls_execv(self):
from nanobot.command.builtin import cmd_restart
from nanobot.command.router import CommandContext
loop, bus = _make_loop() loop, bus = _make_loop()
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop)
with patch("nanobot.agent.loop.os.execv") as mock_execv: with patch("nanobot.command.builtin.os.execv") as mock_execv:
await loop._handle_restart(msg) out = await cmd_restart(ctx)
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "Restarting" in out.content assert "Restarting" in out.content
await asyncio.sleep(1.5) await asyncio.sleep(1.5)
@@ -51,8 +54,8 @@ class TestRestartCommand:
loop, bus = _make_loop() loop, bus = _make_loop()
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/restart") msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/restart")
with patch.object(loop, "_handle_restart") as mock_handle: with patch.object(loop, "_dispatch", new_callable=AsyncMock) as mock_dispatch, \
mock_handle.return_value = None patch("nanobot.command.builtin.os.execv"):
await bus.publish_inbound(msg) await bus.publish_inbound(msg)
loop._running = True loop._running = True
@@ -65,7 +68,9 @@ class TestRestartCommand:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
mock_handle.assert_called_once() mock_dispatch.assert_not_called()
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "Restarting" in out.content
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_status_intercepted_in_run_loop(self): async def test_status_intercepted_in_run_loop(self):
@@ -73,10 +78,7 @@ class TestRestartCommand:
loop, bus = _make_loop() loop, bus = _make_loop()
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status") msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
with patch.object(loop, "_status_response") as mock_status: with patch.object(loop, "_dispatch", new_callable=AsyncMock) as mock_dispatch:
mock_status.return_value = OutboundMessage(
channel="telegram", chat_id="c1", content="status ok"
)
await bus.publish_inbound(msg) await bus.publish_inbound(msg)
loop._running = True loop._running = True
@@ -89,9 +91,9 @@ class TestRestartCommand:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
mock_status.assert_called_once() mock_dispatch.assert_not_called()
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert out.content == "status ok" assert "nanobot" in out.content.lower() or "Model" in out.content
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_propagates_external_cancellation(self): async def test_run_propagates_external_cancellation(self):

View File

@@ -31,16 +31,20 @@ class TestHandleStop:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_no_active_task(self): async def test_stop_no_active_task(self):
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.command.builtin import cmd_stop
from nanobot.command.router import CommandContext
loop, bus = _make_loop() loop, bus = _make_loop()
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop") msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
await loop._handle_stop(msg) ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) out = await cmd_stop(ctx)
assert "No active task" in out.content assert "No active task" in out.content
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_cancels_active_task(self): async def test_stop_cancels_active_task(self):
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.command.builtin import cmd_stop
from nanobot.command.router import CommandContext
loop, bus = _make_loop() loop, bus = _make_loop()
cancelled = asyncio.Event() cancelled = asyncio.Event()
@@ -57,15 +61,17 @@ class TestHandleStop:
loop._active_tasks["test:c1"] = [task] loop._active_tasks["test:c1"] = [task]
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop") msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
await loop._handle_stop(msg) ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
out = await cmd_stop(ctx)
assert cancelled.is_set() assert cancelled.is_set()
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "stopped" in out.content.lower() assert "stopped" in out.content.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_cancels_multiple_tasks(self): async def test_stop_cancels_multiple_tasks(self):
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.command.builtin import cmd_stop
from nanobot.command.router import CommandContext
loop, bus = _make_loop() loop, bus = _make_loop()
events = [asyncio.Event(), asyncio.Event()] events = [asyncio.Event(), asyncio.Event()]
@@ -82,10 +88,10 @@ class TestHandleStop:
loop._active_tasks["test:c1"] = tasks loop._active_tasks["test:c1"] = tasks
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop") msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
await loop._handle_stop(msg) 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 all(e.is_set() for e in events)
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "2 task" in out.content assert "2 task" in out.content