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"
|
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/)"
|
||||||
|
|||||||
@@ -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,49 +325,17 @@ 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))
|
|
||||||
else:
|
|
||||||
task = asyncio.create_task(self._dispatch(msg))
|
task = asyncio.create_task(self._dispatch(msg))
|
||||||
self._active_tasks.setdefault(msg.session_key, []).append(task)
|
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)
|
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."""
|
||||||
async with self._processing_lock:
|
async with self._processing_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"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
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):
|
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: {})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user