refactor command routing for future plugins and clearer CLI structure
This commit is contained in:
@@ -15,7 +15,7 @@ root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
|
||||
printf " %-16s %5s lines\n" "(root)" "$root"
|
||||
|
||||
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 ""
|
||||
echo " (excludes: channels/, cli/, providers/, skills/)"
|
||||
echo " (excludes: channels/, cli/, command/, providers/, skills/)"
|
||||
|
||||
@@ -4,9 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from contextlib import AsyncExitStack
|
||||
from pathlib import Path
|
||||
@@ -14,7 +12,6 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot import __version__
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.memory import MemoryConsolidator
|
||||
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.web import WebFetchTool, WebSearchTool
|
||||
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.providers.base import LLMProvider
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
@@ -118,6 +115,8 @@ class AgentLoop:
|
||||
max_completion_tokens=provider.generation.max_tokens,
|
||||
)
|
||||
self._register_default_tools()
|
||||
self.commands = CommandRouter()
|
||||
register_builtin_commands(self.commands)
|
||||
|
||||
def _register_default_tools(self) -> None:
|
||||
"""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 ", ".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(
|
||||
self,
|
||||
initial_messages: list[dict],
|
||||
@@ -348,48 +325,16 @@ class AgentLoop:
|
||||
logger.warning("Error consuming inbound message: {}, continuing...", e)
|
||||
continue
|
||||
|
||||
cmd = msg.content.strip().lower()
|
||||
if cmd == "/stop":
|
||||
await self._handle_stop(msg)
|
||||
elif cmd == "/restart":
|
||||
await self._handle_restart(msg)
|
||||
elif cmd == "/status":
|
||||
session = self.sessions.get_or_create(msg.session_key)
|
||||
await self.bus.publish_outbound(self._status_response(msg, session))
|
||||
else:
|
||||
task = asyncio.create_task(self._dispatch(msg))
|
||||
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())
|
||||
raw = msg.content.strip()
|
||||
if self.commands.is_priority(raw):
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, loop=self)
|
||||
result = await self.commands.dispatch_priority(ctx)
|
||||
if result:
|
||||
await self.bus.publish_outbound(result)
|
||||
continue
|
||||
task = asyncio.create_task(self._dispatch(msg))
|
||||
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 _dispatch(self, msg: InboundMessage) -> None:
|
||||
"""Process a message under the global lock."""
|
||||
@@ -491,35 +436,11 @@ class AgentLoop:
|
||||
session = self.sessions.get_or_create(key)
|
||||
|
||||
# Slash commands
|
||||
cmd = msg.content.strip().lower()
|
||||
if cmd == "/new":
|
||||
snapshot = session.messages[session.last_consolidated:]
|
||||
session.clear()
|
||||
self.sessions.save(session)
|
||||
self.sessions.invalidate(session.key)
|
||||
raw = msg.content.strip()
|
||||
ctx = CommandContext(msg=msg, session=session, key=key, raw=raw, loop=self)
|
||||
if result := await self.commands.dispatch(ctx):
|
||||
return result
|
||||
|
||||
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)
|
||||
|
||||
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
|
||||
|
||||
@@ -294,7 +294,7 @@ def onboard(
|
||||
|
||||
# Run interactive wizard if enabled
|
||||
if wizard:
|
||||
from nanobot.cli.onboard_wizard import run_onboard
|
||||
from nanobot.cli.onboard import run_onboard
|
||||
|
||||
try:
|
||||
result = run_onboard(initial_config=config)
|
||||
|
||||
@@ -16,7 +16,7 @@ from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from nanobot.cli.model_info import (
|
||||
from nanobot.cli.models import (
|
||||
format_token_count,
|
||||
get_model_context_limit,
|
||||
get_model_suggestions,
|
||||
6
nanobot/command/__init__.py
Normal file
6
nanobot/command/__init__.py
Normal 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
110
nanobot/command/builtin.py
Normal 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
84
nanobot/command/router.py
Normal 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
|
||||
@@ -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):
|
||||
config_file, workspace_dir, _ = mock_paths
|
||||
|
||||
from nanobot.cli.onboard_wizard import OnboardResult
|
||||
from nanobot.cli.onboard import OnboardResult
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.onboard_wizard.run_onboard",
|
||||
"nanobot.cli.onboard.run_onboard",
|
||||
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"
|
||||
workspace_path = tmp_path / "workspace"
|
||||
|
||||
from nanobot.cli.onboard_wizard import OnboardResult
|
||||
from nanobot.cli.onboard import OnboardResult
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.onboard_wizard.run_onboard",
|
||||
"nanobot.cli.onboard.run_onboard",
|
||||
lambda initial_config: OnboardResult(config=initial_config, should_save=True),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {})
|
||||
|
||||
@@ -12,11 +12,11 @@ from typing import Any, cast
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nanobot.cli import onboard_wizard
|
||||
from nanobot.cli import onboard as onboard_wizard
|
||||
|
||||
# Import functions to test
|
||||
from nanobot.cli.commands import _merge_missing_defaults
|
||||
from nanobot.cli.onboard_wizard import (
|
||||
from nanobot.cli.onboard import (
|
||||
_BACK_PRESSED,
|
||||
_configure_pydantic_model,
|
||||
_format_value,
|
||||
@@ -352,7 +352,7 @@ class TestProviderChannelInfo:
|
||||
"""Tests for provider and channel info retrieval."""
|
||||
|
||||
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()
|
||||
assert isinstance(names, dict)
|
||||
@@ -363,7 +363,7 @@ class TestProviderChannelInfo:
|
||||
assert "github_copilot" not in names
|
||||
|
||||
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()
|
||||
assert isinstance(names, dict)
|
||||
@@ -371,7 +371,7 @@ class TestProviderChannelInfo:
|
||||
assert len(names) >= 0
|
||||
|
||||
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()
|
||||
assert isinstance(info, dict)
|
||||
|
||||
@@ -34,12 +34,15 @@ class TestRestartCommand:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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()
|
||||
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:
|
||||
await loop._handle_restart(msg)
|
||||
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
with patch("nanobot.command.builtin.os.execv") as mock_execv:
|
||||
out = await cmd_restart(ctx)
|
||||
assert "Restarting" in out.content
|
||||
|
||||
await asyncio.sleep(1.5)
|
||||
@@ -51,8 +54,8 @@ class TestRestartCommand:
|
||||
loop, bus = _make_loop()
|
||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/restart")
|
||||
|
||||
with patch.object(loop, "_handle_restart") as mock_handle:
|
||||
mock_handle.return_value = None
|
||||
with patch.object(loop, "_dispatch", new_callable=AsyncMock) as mock_dispatch, \
|
||||
patch("nanobot.command.builtin.os.execv"):
|
||||
await bus.publish_inbound(msg)
|
||||
|
||||
loop._running = True
|
||||
@@ -65,7 +68,9 @@ class TestRestartCommand:
|
||||
except asyncio.CancelledError:
|
||||
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
|
||||
async def test_status_intercepted_in_run_loop(self):
|
||||
@@ -73,10 +78,7 @@ class TestRestartCommand:
|
||||
loop, bus = _make_loop()
|
||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
|
||||
|
||||
with patch.object(loop, "_status_response") as mock_status:
|
||||
mock_status.return_value = OutboundMessage(
|
||||
channel="telegram", chat_id="c1", content="status ok"
|
||||
)
|
||||
with patch.object(loop, "_dispatch", new_callable=AsyncMock) as mock_dispatch:
|
||||
await bus.publish_inbound(msg)
|
||||
|
||||
loop._running = True
|
||||
@@ -89,9 +91,9 @@ class TestRestartCommand:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
mock_status.assert_called_once()
|
||||
mock_dispatch.assert_not_called()
|
||||
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
|
||||
async def test_run_propagates_external_cancellation(self):
|
||||
|
||||
@@ -31,16 +31,20 @@ 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")
|
||||
await loop._handle_stop(msg)
|
||||
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
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()
|
||||
@@ -57,15 +61,17 @@ class TestHandleStop:
|
||||
loop._active_tasks["test:c1"] = [task]
|
||||
|
||||
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()
|
||||
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
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()]
|
||||
@@ -82,10 +88,10 @@ class TestHandleStop:
|
||||
loop._active_tasks["test:c1"] = tasks
|
||||
|
||||
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)
|
||||
out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||
assert "2 task" in out.content
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user