mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
Disabling gateway.heartbeat (or agents.defaults.dream) only skipped job registration on startup; the previously persisted system job in <workspace>/cron/jobs.json kept firing, and remove_job refuses to touch protected system jobs. Add CronService.remove_system_job for startup reconciliation and call it from the gateway disabled branches so the config toggle takes effect after restart.
4081 lines
138 KiB
Python
4081 lines
138 KiB
Python
import asyncio
|
|
import json
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import urllib.error
|
|
from contextlib import contextmanager, suppress
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import pytest
|
|
from typer.testing import CliRunner
|
|
|
|
from nanobot.agent.memory import MemoryStore
|
|
from nanobot.agent.tools.registry import ToolRegistry
|
|
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
|
from nanobot.cli import commands as cli_commands
|
|
from nanobot.cli import gateway_runtime as cli_gateway_runtime
|
|
from nanobot.cli import provider as provider_commands
|
|
from nanobot.cli import terminal as cli_terminal
|
|
from nanobot.cli import webui as cli_webui
|
|
from nanobot.cli import webui_support as cli_webui_support
|
|
from nanobot.cli.commands import app
|
|
from nanobot.config.schema import Config
|
|
from nanobot.cron.service import CronJobSkippedError
|
|
from nanobot.cron.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META
|
|
from nanobot.cron.types import CronJob, CronPayload
|
|
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
|
from nanobot.providers.factory import ProviderSnapshot, make_provider, provider_signature
|
|
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
|
from nanobot.providers.registry import find_by_name
|
|
from nanobot.providers.unconfigured_provider import UnconfiguredProvider
|
|
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
|
|
from nanobot.webui.dev import WebUIDevError
|
|
from nanobot.webui.metadata import (
|
|
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
|
|
WEBUI_TURN_METADATA_KEY,
|
|
)
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
def _without_rendered_line_breaks(output: str) -> str:
|
|
return "".join(output.splitlines())
|
|
|
|
|
|
def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None:
|
|
metadata = {
|
|
"webui": True,
|
|
WEBUI_TURN_METADATA_KEY: "turn-that-created-the-reminder",
|
|
"workspace_scope": {"mode": "default"},
|
|
}
|
|
|
|
out = cron_proactive_delivery_metadata(
|
|
"websocket",
|
|
metadata,
|
|
turn_seed="cron:drink-water",
|
|
source_label="drink water",
|
|
)
|
|
|
|
assert out["webui"] is True
|
|
assert out["workspace_scope"] == {"mode": "default"}
|
|
assert out[WEBUI_TURN_METADATA_KEY].startswith("cron:drink-water:")
|
|
assert out[WEBUI_TURN_METADATA_KEY] != metadata[WEBUI_TURN_METADATA_KEY]
|
|
assert out[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {"kind": "cron", "label": "drink water"}
|
|
|
|
|
|
def _fake_provider():
|
|
"""Return a minimal fake provider that satisfies AgentLoop.__init__."""
|
|
p = MagicMock()
|
|
p.generation.max_tokens = 4096
|
|
return p
|
|
|
|
|
|
class _StopGatewayError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class _GatewayAgentContractStub:
|
|
"""Minimal stable AgentLoop surface required by gateway assembly tests."""
|
|
|
|
tools = ToolRegistry()
|
|
|
|
@staticmethod
|
|
def mcp_runtime_status() -> dict[str, str]:
|
|
return {}
|
|
|
|
@staticmethod
|
|
def pending_cron_job_ids_for_session(_session_key: str) -> set[str]:
|
|
return set()
|
|
|
|
@staticmethod
|
|
def pending_local_trigger_ids_for_session(_session_key: str) -> set[str]:
|
|
return set()
|
|
|
|
async def submit_local_trigger_turn(
|
|
self,
|
|
_msg: InboundMessage,
|
|
) -> OutboundMessage | None:
|
|
return None
|
|
|
|
|
|
def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
|
|
class _FakeLoop:
|
|
def __init__(self) -> None:
|
|
self.handlers: dict[int, tuple[object, tuple[object, ...]]] = {}
|
|
self.removed: list[int] = []
|
|
|
|
def add_signal_handler(self, signum, callback, *args) -> None:
|
|
self.handlers[int(signum)] = (callback, args)
|
|
|
|
def remove_signal_handler(self, signum) -> bool:
|
|
self.removed.append(int(signum))
|
|
self.handlers.pop(int(signum), None)
|
|
return True
|
|
|
|
async def _run() -> None:
|
|
loop = _FakeLoop()
|
|
shutdown_event = asyncio.Event()
|
|
never = asyncio.Event()
|
|
task = asyncio.create_task(never.wait())
|
|
output: list[str] = []
|
|
|
|
restore = cli_gateway_runtime._install_gateway_shutdown_handlers(
|
|
loop, shutdown_event, [task], output.append,
|
|
)
|
|
try:
|
|
callback, args = loop.handlers[int(signal.SIGINT)]
|
|
assert callable(callback)
|
|
|
|
callback(*args)
|
|
assert shutdown_event.is_set()
|
|
assert output == ["\nShutting down... Press Ctrl+C again to force."]
|
|
assert not task.done()
|
|
|
|
callback(*args)
|
|
await asyncio.sleep(0)
|
|
assert task.cancelled()
|
|
finally:
|
|
restore()
|
|
task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert int(signal.SIGINT) in loop.removed
|
|
assert int(signal.SIGTERM) in loop.removed
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_interactive_tty_mode_restores_line_input(monkeypatch) -> None:
|
|
try:
|
|
import os
|
|
import pty
|
|
import termios
|
|
except ImportError: # pragma: no cover - platform without POSIX termios
|
|
pytest.skip("termios unavailable")
|
|
|
|
master_fd, slave_fd = pty.openpty()
|
|
|
|
class _Stdin:
|
|
def fileno(self) -> int:
|
|
return slave_fd
|
|
|
|
try:
|
|
attrs = termios.tcgetattr(slave_fd)
|
|
attrs[0] &= ~termios.ICRNL
|
|
attrs[0] |= termios.IGNCR
|
|
attrs[3] &= ~(termios.ISIG | termios.ICANON | termios.ECHO)
|
|
termios.tcsetattr(slave_fd, termios.TCSANOW, attrs)
|
|
|
|
monkeypatch.setattr(cli_terminal.sys, "stdin", _Stdin())
|
|
cli_terminal._ensure_interactive_tty_mode()
|
|
|
|
restored = termios.tcgetattr(slave_fd)
|
|
assert restored[0] & termios.ICRNL
|
|
assert not restored[0] & termios.IGNCR
|
|
assert restored[3] & termios.ISIG
|
|
assert restored[3] & termios.ICANON
|
|
assert restored[3] & termios.ECHO
|
|
finally:
|
|
os.close(master_fd)
|
|
os.close(slave_fd)
|
|
|
|
|
|
def test_webui_restores_tty_before_loading_config(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text("{}", encoding="utf-8")
|
|
calls: list[str] = []
|
|
original_resolve = cli_webui._resolve_webui_config_path
|
|
|
|
monkeypatch.setattr(
|
|
cli_terminal,
|
|
"_ensure_interactive_tty_mode",
|
|
lambda: calls.append("tty"),
|
|
)
|
|
monkeypatch.setattr(
|
|
cli_webui,
|
|
"_resolve_webui_config_path",
|
|
lambda path: calls.append("config") or original_resolve(path),
|
|
)
|
|
_patch_webui_provider_ready(monkeypatch)
|
|
monkeypatch.setattr(cli_webui, "sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr(cli_webui, "_gateway_health_ready", lambda *_args, **_kwargs: False)
|
|
monkeypatch.setattr(cli_webui, "_webui_endpoint_reachable", lambda *_args, **_kwargs: False)
|
|
monkeypatch.setattr(cli_webui, "_tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
|
_patch_webui_managed_gateway(monkeypatch)
|
|
|
|
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
|
|
|
|
assert result.exit_code == 0
|
|
assert calls[:2] == ["tty", "config"]
|
|
|
|
|
|
def test_disabled_dream_cursor_only_advances_when_behind(tmp_path) -> None:
|
|
store = MemoryStore(tmp_path)
|
|
store.append_history("first")
|
|
store.append_history("second")
|
|
|
|
cli_gateway_runtime._advance_dream_cursor_if_behind(store)
|
|
assert store.get_last_dream_cursor() == 2
|
|
|
|
store.set_last_dream_cursor(10)
|
|
cli_gateway_runtime._advance_dream_cursor_if_behind(store)
|
|
assert store.get_last_dream_cursor() == 10
|
|
|
|
|
|
def test_commit_dream_changes_skips_noop_run(tmp_path) -> None:
|
|
store = MemoryStore(tmp_path)
|
|
store.write_soul("# Soul")
|
|
store.write_memory("# Memory")
|
|
store.git.init()
|
|
store.git.auto_commit("initial")
|
|
store.git.auto_commit = MagicMock(wraps=store.git.auto_commit)
|
|
|
|
assert cli_gateway_runtime._commit_dream_changes(store) is None
|
|
store.git.auto_commit.assert_not_called()
|
|
|
|
|
|
def test_commit_dream_changes_commits_real_edits(tmp_path) -> None:
|
|
store = MemoryStore(tmp_path)
|
|
store.write_soul("# Soul")
|
|
store.write_memory("# Memory")
|
|
store.git.init()
|
|
store.git.auto_commit("initial")
|
|
store.write_memory("# Memory\n- Research notes")
|
|
store.git.auto_commit = MagicMock(wraps=store.git.auto_commit)
|
|
|
|
sha = cli_gateway_runtime._commit_dream_changes(store)
|
|
|
|
assert sha is not None
|
|
store.git.auto_commit.assert_called_once()
|
|
message = store.git.auto_commit.call_args.args[0]
|
|
assert message.startswith("dream: periodic memory consolidation\n\n")
|
|
assert "Research notes" in message
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_paths():
|
|
"""Mock config/workspace paths for test isolation."""
|
|
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
|
|
patch("nanobot.config.loader.save_config") as mock_sc, \
|
|
patch("nanobot.config.loader.load_config") as mock_lc, \
|
|
patch("nanobot.cli.commands.get_workspace_path") as mock_ws:
|
|
base_dir = Path("./test_onboard_data")
|
|
if base_dir.exists():
|
|
shutil.rmtree(base_dir)
|
|
base_dir.mkdir()
|
|
|
|
config_file = base_dir / "config.json"
|
|
workspace_dir = base_dir / "workspace"
|
|
|
|
mock_cp.return_value = config_file
|
|
mock_ws.return_value = workspace_dir
|
|
mock_lc.side_effect = lambda _config_path=None: Config()
|
|
|
|
def _save_config(config: Config, config_path: Path | None = None):
|
|
target = config_path or config_file
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(json.dumps(config.model_dump(by_alias=True)), encoding="utf-8")
|
|
|
|
mock_sc.side_effect = _save_config
|
|
|
|
yield config_file, workspace_dir, mock_ws
|
|
|
|
if base_dir.exists():
|
|
shutil.rmtree(base_dir)
|
|
|
|
|
|
def test_onboard_fresh_install(mock_paths):
|
|
"""No existing config — should create from scratch."""
|
|
config_file, workspace_dir, mock_ws = mock_paths
|
|
|
|
result = runner.invoke(app, ["onboard"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Created config" in result.stdout
|
|
assert "Created workspace" in result.stdout
|
|
assert "nanobot is ready" in result.stdout
|
|
assert config_file.exists()
|
|
assert (workspace_dir / "AGENTS.md").exists()
|
|
assert (workspace_dir / "memory" / "MEMORY.md").exists()
|
|
expected_workspace = Config().workspace_path
|
|
assert mock_ws.call_args.args == (expected_workspace,)
|
|
|
|
|
|
def test_onboard_recommends_webui(mock_paths):
|
|
"""Default onboarding should recommend the guided WebUI launcher."""
|
|
result = runner.invoke(app, ["onboard"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "✓ nanobot is ready. Run: nanobot webui" in result.stdout
|
|
|
|
|
|
def test_onboard_existing_config_refresh(mock_paths):
|
|
"""Config exists, user declines overwrite — should refresh (load-merge-save)."""
|
|
config_file, workspace_dir, _ = mock_paths
|
|
config_file.write_text('{"existing": true}')
|
|
|
|
result = runner.invoke(app, ["onboard"], input="n\n")
|
|
|
|
assert result.exit_code == 0
|
|
assert "Config already exists" in result.stdout
|
|
assert "existing values preserved" in result.stdout
|
|
assert workspace_dir.exists()
|
|
assert (workspace_dir / "AGENTS.md").exists()
|
|
|
|
|
|
def test_onboard_existing_config_refresh_non_interactive(mock_paths):
|
|
"""Config exists, user specifies --refresh — should refresh non-interactively (no prompt)."""
|
|
config_file, workspace_dir, _ = mock_paths
|
|
config_file.write_text('{"existing": true}')
|
|
|
|
result = runner.invoke(app, ["onboard", "--refresh"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Config already exists" not in result.stdout
|
|
assert "existing values preserved" in result.stdout
|
|
assert workspace_dir.exists()
|
|
assert (workspace_dir / "AGENTS.md").exists()
|
|
|
|
|
|
def test_onboard_existing_config_overwrite(mock_paths):
|
|
"""Config exists, user confirms overwrite — should reset to defaults."""
|
|
config_file, workspace_dir, _ = mock_paths
|
|
config_file.write_text('{"existing": true}')
|
|
|
|
result = runner.invoke(app, ["onboard"], input="y\n")
|
|
|
|
assert result.exit_code == 0
|
|
assert "Config already exists" in result.stdout
|
|
assert "Config reset to defaults" in result.stdout
|
|
assert workspace_dir.exists()
|
|
|
|
|
|
def test_onboard_existing_workspace_safe_create(mock_paths):
|
|
"""Workspace exists — should not recreate, but still add missing templates."""
|
|
config_file, workspace_dir, _ = mock_paths
|
|
workspace_dir.mkdir(parents=True)
|
|
config_file.write_text("{}")
|
|
|
|
result = runner.invoke(app, ["onboard"], input="n\n")
|
|
|
|
assert result.exit_code == 0
|
|
assert "Created workspace" not in result.stdout
|
|
assert "Created AGENTS.md" in result.stdout
|
|
assert (workspace_dir / "AGENTS.md").exists()
|
|
|
|
|
|
def _strip_ansi(text):
|
|
"""Remove ANSI escape codes from text."""
|
|
ansi_escape = re.compile(r'\x1b\[[0-9;]*m')
|
|
return ansi_escape.sub('', text)
|
|
|
|
|
|
def test_onboard_help_shows_workspace_and_config_options():
|
|
result = runner.invoke(app, ["onboard", "--help"])
|
|
|
|
assert result.exit_code == 0
|
|
stripped_output = _strip_ansi(result.stdout)
|
|
assert "--workspace" in stripped_output
|
|
assert "-w" in stripped_output
|
|
assert "--config" in stripped_output
|
|
assert "-c" in stripped_output
|
|
assert "--wizard" in stripped_output
|
|
assert "--refresh" in stripped_output
|
|
assert "--dir" not in stripped_output
|
|
|
|
|
|
def test_status_help_shows_workspace_and_config_options():
|
|
result = runner.invoke(app, ["status", "--help"])
|
|
|
|
assert result.exit_code == 0
|
|
stripped_output = _strip_ansi(result.stdout)
|
|
assert "--workspace" in stripped_output
|
|
assert "-w" in stripped_output
|
|
assert "--config" in stripped_output
|
|
assert "-c" in stripped_output
|
|
|
|
|
|
def test_status_uses_explicit_config_and_workspace(tmp_path: Path):
|
|
config_path = tmp_path / "instance" / "config.json"
|
|
config_workspace = tmp_path / "config-workspace"
|
|
override_workspace = tmp_path / "override-workspace"
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(config_workspace)
|
|
config_path.parent.mkdir(parents=True)
|
|
config_path.write_text(json.dumps(config.model_dump(mode="json", by_alias=True)))
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["status", "--config", str(config_path), "--workspace", str(override_workspace)],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
stripped_output = _strip_ansi(result.stdout)
|
|
compact_output = stripped_output.replace("\n", "")
|
|
assert str(config_path.resolve(strict=False)) in compact_output
|
|
assert str(override_workspace) in compact_output
|
|
assert str(config_workspace) not in compact_output
|
|
|
|
|
|
def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch):
|
|
config_file, workspace_dir, _ = mock_paths
|
|
|
|
from nanobot.cli.onboard import OnboardResult
|
|
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.onboard.run_onboard",
|
|
lambda initial_config: OnboardResult(config=initial_config, should_save=False),
|
|
)
|
|
|
|
result = runner.invoke(app, ["onboard", "--wizard"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "No changes were saved" in result.stdout
|
|
assert not config_file.exists()
|
|
assert not workspace_dir.exists()
|
|
|
|
|
|
def test_onboard_uses_explicit_config_and_workspace_paths(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "instance" / "config.json"
|
|
workspace_path = tmp_path / "workspace"
|
|
|
|
monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {})
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["onboard", "--config", str(config_path), "--workspace", str(workspace_path)],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
|
assert saved.workspace_path == workspace_path
|
|
assert (workspace_path / "AGENTS.md").exists()
|
|
stripped_output = _strip_ansi(result.stdout)
|
|
compact_output = stripped_output.replace("\n", "")
|
|
resolved_config = str(config_path.resolve())
|
|
assert resolved_config in compact_output
|
|
assert f'nanobot webui -c "{resolved_config}"' in result.stdout
|
|
|
|
|
|
def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "instance" / "config.json"
|
|
workspace_path = tmp_path / "workspace"
|
|
|
|
from nanobot.cli.onboard import OnboardResult
|
|
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.onboard.run_onboard",
|
|
lambda initial_config: OnboardResult(config=initial_config, should_save=True),
|
|
)
|
|
monkeypatch.setattr("nanobot.channels.registry.discover_all", lambda: {})
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["onboard", "--wizard", "--config", str(config_path), "--workspace", str(workspace_path)],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
resolved_config = str(config_path.resolve())
|
|
assert f'nanobot webui -c "{resolved_config}"' in result.stdout
|
|
|
|
|
|
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
|
|
config = Config()
|
|
config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
|
|
|
|
assert config.get_provider_name() == "github_copilot"
|
|
|
|
|
|
def test_config_matches_openai_codex_with_hyphen_prefix():
|
|
config = Config()
|
|
config.agents.defaults.model = "openai-codex/gpt-5.6-sol"
|
|
|
|
assert config.get_provider_name() == "openai_codex"
|
|
|
|
|
|
def test_openai_codex_oauth_default_matches_curated_flagship():
|
|
spec = find_by_name("openai_codex")
|
|
|
|
assert spec is not None
|
|
assert spec.builtin_models
|
|
assert provider_commands._OAUTH_PROVIDER_DEFAULT_MODELS["openai_codex"] == (
|
|
spec.builtin_models[0].id
|
|
)
|
|
|
|
|
|
def test_config_dump_excludes_oauth_provider_blocks():
|
|
config = Config()
|
|
|
|
providers = config.model_dump(by_alias=True)["providers"]
|
|
|
|
assert "openaiCodex" not in providers
|
|
assert "xaiGrok" not in providers
|
|
assert "githubCopilot" not in providers
|
|
|
|
|
|
def test_plugins_list_uses_explicit_config(monkeypatch, tmp_path: Path):
|
|
from nanobot.channels.plugin import ChannelPlugin
|
|
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text(
|
|
json.dumps({"channels": {"example": {"enabled": True}}}),
|
|
encoding="utf-8",
|
|
)
|
|
plugin = ChannelPlugin(
|
|
name="example",
|
|
display_name="Example",
|
|
runtime="example.runtime:ExampleChannel",
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.channels.registry.discover_plugins",
|
|
lambda enabled_names=None: (
|
|
{"example": plugin}
|
|
if enabled_names is None or "example" in enabled_names
|
|
else {}
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.optional_features.optional_dependency_groups",
|
|
lambda: {},
|
|
)
|
|
|
|
result = runner.invoke(app, ["plugins", "list", "--config", str(config_path)])
|
|
|
|
assert result.exit_code == 0
|
|
stripped_output = _strip_ansi(result.stdout)
|
|
assert "example" in stripped_output
|
|
assert "yes" in stripped_output
|
|
|
|
|
|
def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkeypatch):
|
|
token_path = tmp_path / "auth" / "codex.json"
|
|
lock_path = token_path.with_suffix(".lock")
|
|
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
token_path.write_text("{}", encoding="utf-8")
|
|
lock_path.write_text("", encoding="utf-8")
|
|
monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path))
|
|
|
|
result = runner.invoke(app, ["provider", "logout", "openai-codex"])
|
|
|
|
assert result.exit_code == 0
|
|
assert not token_path.exists()
|
|
assert not lock_path.exists()
|
|
assert "Logged out from OpenAI Codex" in result.stdout
|
|
|
|
|
|
def test_provider_logout_openai_codex_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path):
|
|
token_path = tmp_path / "auth" / "codex.json"
|
|
monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path))
|
|
|
|
result = runner.invoke(app, ["provider", "logout", "openai-codex"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "No local OAuth credentials found for OpenAI Codex" in result.stdout
|
|
|
|
|
|
def test_provider_logout_xai_grok_removes_instance_credentials(tmp_path, monkeypatch):
|
|
token_path = tmp_path / "auth" / "xai.json"
|
|
lock_path = token_path.with_suffix(".lock")
|
|
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
token_path.write_text("{}", encoding="utf-8")
|
|
lock_path.write_text("", encoding="utf-8")
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.xai_oauth.get_xai_oauth_storage_path",
|
|
lambda: token_path,
|
|
)
|
|
|
|
result = runner.invoke(app, ["provider", "logout", "xai-grok"])
|
|
|
|
assert result.exit_code == 0
|
|
assert not token_path.exists()
|
|
assert "Logged out from xAI Grok" in result.stdout
|
|
|
|
|
|
def test_provider_logout_xai_grok_uses_explicit_config_path(tmp_path, monkeypatch):
|
|
from nanobot.config import loader
|
|
|
|
default_config = tmp_path / "default" / "config.json"
|
|
selected_config = tmp_path / "selected" / "config.json"
|
|
default_token = default_config.parent / "auth" / "xai.json"
|
|
selected_token = selected_config.parent / "auth" / "xai.json"
|
|
for token_path in (default_token, selected_token):
|
|
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
token_path.write_text("{}", encoding="utf-8")
|
|
monkeypatch.setattr(loader, "_current_config_path", default_config)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["provider", "logout", "xai-grok", "--config", str(selected_config)],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert default_token.exists()
|
|
assert not selected_token.exists()
|
|
assert "Using config:" in result.stdout
|
|
|
|
|
|
def test_provider_logout_github_copilot_removes_local_oauth_files(tmp_path, monkeypatch):
|
|
token_path = tmp_path / "auth" / "github-copilot.json"
|
|
lock_path = token_path.with_suffix(".lock")
|
|
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
token_path.write_text("{}", encoding="utf-8")
|
|
lock_path.write_text("", encoding="utf-8")
|
|
monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path))
|
|
|
|
result = runner.invoke(app, ["provider", "logout", "github-copilot"])
|
|
|
|
assert result.exit_code == 0
|
|
assert not token_path.exists()
|
|
assert not lock_path.exists()
|
|
assert "Logged out from GitHub Copilot" in result.stdout
|
|
|
|
|
|
def test_provider_logout_github_copilot_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path):
|
|
token_path = tmp_path / "auth" / "github-copilot.json"
|
|
monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path))
|
|
|
|
result = runner.invoke(app, ["provider", "logout", "github-copilot"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "No local OAuth credentials found for GitHub Copilot" in result.stdout
|
|
|
|
|
|
def test_provider_logout_rejects_unknown_provider():
|
|
result = runner.invoke(app, ["provider", "logout", "not-a-real-provider"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "Unknown OAuth provider" in result.stdout
|
|
|
|
|
|
def test_provider_logout_paths_resolve_to_expected_files():
|
|
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
|
from oauth_cli_kit.storage import FileTokenStorage
|
|
|
|
from nanobot.providers.github_copilot_provider import get_storage
|
|
from nanobot.providers.xai_oauth import get_xai_oauth_storage_path
|
|
|
|
codex_storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
|
|
codex_path = codex_storage.get_token_path()
|
|
assert codex_path.name == "codex.json"
|
|
assert codex_path.parent.name == "auth"
|
|
|
|
xai_path = get_xai_oauth_storage_path()
|
|
assert xai_path.name == "xai.json"
|
|
assert xai_path.parent.name == "auth"
|
|
|
|
gh_storage = get_storage()
|
|
gh_path = gh_storage.get_token_path()
|
|
assert gh_path.name == "github-copilot.json"
|
|
assert gh_path.parent.name == "auth"
|
|
|
|
|
|
def test_provider_login_rejects_unknown_provider():
|
|
result = runner.invoke(app, ["provider", "login", "not-a-real-provider"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "Unknown OAuth provider" in result.stdout
|
|
|
|
|
|
def test_provider_login_openai_codex_handles_missing_oauth_symbol(monkeypatch):
|
|
import oauth_cli_kit
|
|
|
|
monkeypatch.delattr(oauth_cli_kit, "get_token")
|
|
|
|
result = runner.invoke(app, ["provider", "login", "openai-codex"])
|
|
|
|
assert result.exit_code == 1
|
|
assert (
|
|
"This nanobot installation is missing the required oauth-cli-kit package. "
|
|
"Reinstall or upgrade nanobot-ai using the same installation method."
|
|
) in re.sub(r"\s+", " ", result.stdout)
|
|
assert result.exception is not None
|
|
|
|
|
|
def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
|
|
config_path = tmp_path / "config.json"
|
|
called = False
|
|
original = provider_commands._LOGIN_HANDLERS["openai_codex"]
|
|
|
|
def fake_login() -> None:
|
|
nonlocal called
|
|
called = True
|
|
|
|
provider_commands._LOGIN_HANDLERS["openai_codex"] = fake_login
|
|
try:
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"provider",
|
|
"login",
|
|
"openai-codex",
|
|
"--set-main",
|
|
"--config",
|
|
str(config_path),
|
|
],
|
|
)
|
|
finally:
|
|
provider_commands._LOGIN_HANDLERS["openai_codex"] = original
|
|
|
|
assert result.exit_code == 0
|
|
assert called is True
|
|
assert "Set openai-codex as the main provider" in result.stdout
|
|
|
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
|
assert saved.agents.defaults.provider == "openai_codex"
|
|
assert saved.agents.defaults.model == "openai-codex/gpt-5.6-sol"
|
|
assert saved.agents.defaults.model_preset is None
|
|
assert make_provider(saved).__class__.__name__ == "OpenAICodexProvider"
|
|
|
|
|
|
def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
|
|
config_path = tmp_path / "config.json"
|
|
original = provider_commands._LOGIN_HANDLERS["github_copilot"]
|
|
provider_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
|
|
try:
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"provider",
|
|
"login",
|
|
"github-copilot",
|
|
"--set-main",
|
|
"--config",
|
|
str(config_path),
|
|
],
|
|
)
|
|
finally:
|
|
provider_commands._LOGIN_HANDLERS["github_copilot"] = original
|
|
|
|
assert result.exit_code == 0
|
|
assert "Set github-copilot as the main provider" in result.stdout
|
|
|
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
|
assert saved.agents.defaults.provider == "github_copilot"
|
|
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
|
|
assert saved.agents.defaults.model_preset is None
|
|
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
|
|
|
|
|
def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
|
|
config_path = tmp_path / "config.json"
|
|
original = provider_commands._LOGIN_HANDLERS["xai_grok"]
|
|
provider_commands._LOGIN_HANDLERS["xai_grok"] = lambda: None
|
|
try:
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"provider",
|
|
"login",
|
|
"xai-grok",
|
|
"--set-main",
|
|
"--config",
|
|
str(config_path),
|
|
],
|
|
)
|
|
finally:
|
|
provider_commands._LOGIN_HANDLERS["xai_grok"] = original
|
|
|
|
assert result.exit_code == 0
|
|
assert "Set xai-grok as the main provider" in result.stdout
|
|
|
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
|
assert saved.agents.defaults.provider == "xai_grok"
|
|
assert saved.agents.defaults.model == "xai-grok/grok-4.5"
|
|
assert saved.agents.defaults.context_window_tokens == 500_000
|
|
assert saved.agents.defaults.model_preset is None
|
|
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
|
|
|
|
|
|
def test_provider_login_model_implies_set_main_provider(tmp_path):
|
|
config_path = tmp_path / "config.json"
|
|
original = provider_commands._LOGIN_HANDLERS["github_copilot"]
|
|
provider_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
|
|
try:
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"provider",
|
|
"login",
|
|
"github-copilot",
|
|
"--model",
|
|
"github-copilot/gpt-5.4-mini",
|
|
"--config",
|
|
str(config_path),
|
|
],
|
|
)
|
|
finally:
|
|
provider_commands._LOGIN_HANDLERS["github_copilot"] = original
|
|
|
|
assert result.exit_code == 0
|
|
assert "Set github-copilot as the main provider" in result.stdout
|
|
|
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
|
assert saved.agents.defaults.provider == "github_copilot"
|
|
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
|
|
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
|
|
|
|
|
def test_provider_login_openai_codex_passes_configured_proxy(monkeypatch):
|
|
proxy = "http://127.0.0.1:23458"
|
|
monkeypatch.setattr(
|
|
"nanobot.config.loader.load_config",
|
|
lambda: Config.model_validate({"providers": {"openaiCodex": {"proxy": proxy}}}),
|
|
)
|
|
|
|
import oauth_cli_kit
|
|
|
|
def fake_get_token(**_kwargs):
|
|
raise RuntimeError("no-token")
|
|
|
|
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
|
|
|
captured: dict[str, str | None] = {}
|
|
|
|
def fake_login(*, print_fn, prompt_fn, proxy=None):
|
|
captured["proxy"] = proxy
|
|
return SimpleNamespace(access="access-token", account_id="acct-test")
|
|
|
|
monkeypatch.setattr(oauth_cli_kit, "login_oauth_interactive", fake_login)
|
|
|
|
result = runner.invoke(app, ["provider", "login", "openai-codex"])
|
|
|
|
assert result.exit_code == 0
|
|
assert captured["proxy"] == proxy
|
|
|
|
|
|
def test_provider_login_openai_codex_uses_explicit_config_proxy(tmp_path, monkeypatch):
|
|
from nanobot.config import loader
|
|
|
|
proxy = "http://127.0.0.1:23458"
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text(
|
|
json.dumps({"providers": {"openaiCodex": {"proxy": proxy}}}),
|
|
encoding="utf-8",
|
|
)
|
|
active_path: dict[str, Path] = {}
|
|
real_load_config = loader.load_config
|
|
|
|
def fake_set_config_path(path: Path) -> None:
|
|
active_path["path"] = path
|
|
|
|
def fake_load_config(config_path: Path | None = None) -> Config:
|
|
path = config_path or active_path.get("path")
|
|
if path is None:
|
|
return Config.model_validate(
|
|
{"providers": {"openaiCodex": {"proxy": "http://default-proxy:8080"}}}
|
|
)
|
|
return real_load_config(path)
|
|
|
|
monkeypatch.setattr(loader, "set_config_path", fake_set_config_path)
|
|
monkeypatch.setattr(loader, "load_config", fake_load_config)
|
|
|
|
import oauth_cli_kit
|
|
|
|
captured: dict[str, str | None] = {}
|
|
|
|
def fake_get_token(*, proxy=None):
|
|
captured["proxy"] = proxy
|
|
return SimpleNamespace(access="access-token", account_id="acct-test")
|
|
|
|
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["provider", "login", "openai-codex", "--config", str(config_path)],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert active_path["path"] == config_path.resolve()
|
|
assert captured["proxy"] == proxy
|
|
|
|
|
|
def test_provider_login_openai_codex_resolves_proxy_env_ref(monkeypatch):
|
|
proxy = "http://127.0.0.1:23458"
|
|
monkeypatch.setenv("CODEX_PROXY_FOR_TEST", proxy)
|
|
monkeypatch.setattr(
|
|
"nanobot.config.loader.load_config",
|
|
lambda: Config.model_validate(
|
|
{"providers": {"openaiCodex": {"proxy": "${CODEX_PROXY_FOR_TEST}"}}}
|
|
),
|
|
)
|
|
|
|
import oauth_cli_kit
|
|
|
|
captured: dict[str, str | None] = {}
|
|
|
|
def fake_get_token(*, proxy=None):
|
|
captured["proxy"] = proxy
|
|
return SimpleNamespace(access="access-token", account_id="acct-test")
|
|
|
|
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
|
|
|
result = runner.invoke(app, ["provider", "login", "openai-codex"])
|
|
|
|
assert result.exit_code == 0
|
|
assert captured["proxy"] == proxy
|
|
|
|
|
|
def test_provider_login_xai_grok_runs_browser_flow_with_configured_proxy(monkeypatch):
|
|
proxy = "http://127.0.0.1:23458"
|
|
monkeypatch.setattr(
|
|
"nanobot.config.loader.load_config",
|
|
lambda: Config.model_validate({"providers": {"xaiGrok": {"proxy": proxy}}}),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.xai_oauth.get_xai_oauth_token",
|
|
lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("not signed in")),
|
|
)
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_login(*, print_fn, prompt_fn, proxy=None):
|
|
captured.update(print_fn=print_fn, prompt_fn=prompt_fn, proxy=proxy)
|
|
return SimpleNamespace(access="access-token", account_id="user@example.com")
|
|
|
|
monkeypatch.setattr("nanobot.providers.xai_oauth.login_xai_oauth", fake_login)
|
|
|
|
result = runner.invoke(app, ["provider", "login", "xai-grok"])
|
|
|
|
assert result.exit_code == 0
|
|
assert captured["proxy"] == proxy
|
|
assert callable(captured["print_fn"])
|
|
assert callable(captured["prompt_fn"])
|
|
assert "Hosted X Search is enabled automatically when the selected model supports it" in result.stdout
|
|
|
|
|
|
def test_config_matches_explicit_ollama_prefix_without_api_key():
|
|
config = Config()
|
|
config.agents.defaults.model = "ollama/llama3.2"
|
|
|
|
assert config.get_provider_name() == "ollama"
|
|
assert config.get_api_base() == "http://localhost:11434/v1"
|
|
|
|
|
|
def test_config_explicit_ollama_provider_uses_default_localhost_api_base():
|
|
config = Config()
|
|
config.agents.defaults.provider = "ollama"
|
|
config.agents.defaults.model = "llama3.2"
|
|
|
|
assert config.get_provider_name() == "ollama"
|
|
assert config.get_api_base() == "http://localhost:11434/v1"
|
|
|
|
|
|
def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "volcengineCodingPlan",
|
|
"model": "doubao-1-5-pro",
|
|
}
|
|
},
|
|
"providers": {
|
|
"volcengineCodingPlan": {
|
|
"apiKey": "test-key",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "volcengine_coding_plan"
|
|
assert config.get_api_base() == "https://ark.cn-beijing.volces.com/api/coding/v3"
|
|
|
|
|
|
def test_config_accepts_lm_studio_without_api_key_and_uses_default_localhost_api_base():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "lm_studio",
|
|
"model": "local-model",
|
|
}
|
|
},
|
|
"providers": {
|
|
"lmStudio": {
|
|
"apiKey": None,
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "lm_studio"
|
|
assert config.get_api_key() is None
|
|
assert config.get_api_base() == "http://localhost:1234/v1"
|
|
|
|
|
|
def test_config_accepts_atomic_chat_without_api_key_and_uses_default_localhost_api_base():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "atomic_chat",
|
|
"model": "local-model",
|
|
}
|
|
},
|
|
"providers": {
|
|
"atomicChat": {
|
|
"apiKey": None,
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "atomic_chat"
|
|
assert config.get_api_key() is None
|
|
assert config.get_api_base() == "http://localhost:1337/v1"
|
|
|
|
|
|
def test_find_by_name_accepts_camel_case_and_hyphen_aliases():
|
|
assert find_by_name("volcengineCodingPlan") is not None
|
|
assert find_by_name("volcengineCodingPlan").name == "volcengine_coding_plan"
|
|
assert find_by_name("github-copilot") is not None
|
|
assert find_by_name("github-copilot").name == "github_copilot"
|
|
assert find_by_name("longcat") is not None
|
|
assert find_by_name("longcat").name == "longcat"
|
|
assert find_by_name("atomic-chat") is not None
|
|
assert find_by_name("atomic-chat").name == "atomic_chat"
|
|
|
|
|
|
def test_config_explicit_longcat_provider_resolves_provider_name():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "longcat",
|
|
"model": "LongCat-Flash-Chat",
|
|
}
|
|
},
|
|
"providers": {
|
|
"longcat": {
|
|
"apiKey": "test-key",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "longcat"
|
|
assert config.get_api_base() == "https://api.longcat.chat/openai/v1"
|
|
|
|
|
|
def test_config_auto_detects_longcat_from_model_keyword():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "auto", "model": "longcat/LongCat-Flash-Chat"}},
|
|
"providers": {"longcat": {"apiKey": "test-key"}},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "longcat"
|
|
|
|
|
|
def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "xiaomi_mimo",
|
|
"model": "MiniMax-M1-80k",
|
|
}
|
|
},
|
|
"providers": {
|
|
"xiaomiMimo": {
|
|
"apiKey": "test-key",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "xiaomi_mimo"
|
|
assert config.get_api_base() == "https://api.xiaomimimo.com/v1"
|
|
|
|
|
|
def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "auto", "model": "mimo/MiniMax-M1-80k"}},
|
|
"providers": {"xiaomiMimo": {"apiKey": "test-key"}},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "xiaomi_mimo"
|
|
assert config.get_api_base() == "https://api.xiaomimimo.com/v1"
|
|
|
|
|
|
def test_config_explicit_minimax_anthropic_provider_uses_default_api_base():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "minimax_anthropic",
|
|
"model": "MiniMax-M2.7-highspeed",
|
|
}
|
|
},
|
|
"providers": {
|
|
"minimaxAnthropic": {
|
|
"apiKey": "test-key",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "minimax_anthropic"
|
|
assert config.get_api_key() == "test-key"
|
|
assert config.get_api_base() == "https://api.minimax.io/anthropic"
|
|
|
|
|
|
def test_config_auto_detects_ollama_from_local_api_base():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
|
|
"providers": {"ollama": {"apiBase": "http://localhost:11434/v1"}},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "ollama"
|
|
assert config.get_api_base() == "http://localhost:11434/v1"
|
|
|
|
|
|
def test_config_prefers_ollama_over_vllm_when_both_local_providers_configured():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
|
|
"providers": {
|
|
"vllm": {"apiBase": "http://localhost:8000"},
|
|
"ollama": {"apiBase": "http://localhost:11434/v1"},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "ollama"
|
|
assert config.get_api_base() == "http://localhost:11434/v1"
|
|
|
|
|
|
def test_config_falls_back_to_vllm_when_ollama_not_configured():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
|
|
"providers": {
|
|
"vllm": {"apiBase": "http://localhost:8000"},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "vllm"
|
|
assert config.get_api_base() == "http://localhost:8000"
|
|
|
|
|
|
def test_config_cloud_nemotron_is_not_hijacked_by_unconfigured_ollama():
|
|
"""`nvidia/nemotron-*` via a gateway must not route to Ollama when no
|
|
Ollama endpoint is configured. Ollama keeps "nemotron" in its keywords
|
|
for bare-model auto-routing (PR #1863), which previously hijacked
|
|
cloud-hosted nemotron variants and silently sent traffic to
|
|
http://localhost:11434/v1."""
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "auto",
|
|
"model": "nvidia/nemotron-3-super-120b-a12b",
|
|
}
|
|
},
|
|
"providers": {"openrouter": {"apiKey": "sk-or-test"}},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "openrouter"
|
|
assert config.get_api_base() == "https://openrouter.ai/api/v1"
|
|
|
|
|
|
def test_config_bare_nemotron_still_auto_routes_to_configured_ollama():
|
|
"""Preserves PR #1863 intent: when the user has actually configured an
|
|
Ollama endpoint, a bare nemotron model still auto-routes there."""
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "auto", "model": "nemotron-3-nano"}},
|
|
"providers": {"ollama": {"apiBase": "http://localhost:11434/v1"}},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "ollama"
|
|
assert config.get_api_base() == "http://localhost:11434/v1"
|
|
|
|
|
|
def test_config_cloud_nemotron_is_not_hijacked_by_configured_ollama():
|
|
"""An explicit cloud namespace takes precedence over local keywords."""
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "auto",
|
|
"model": "nvidia/nemotron-3-super-120b-a12b",
|
|
}
|
|
},
|
|
"providers": {
|
|
"ollama": {"apiBase": "http://localhost:11434/v1"},
|
|
"openrouter": {"apiKey": "sk-or-test"},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert config.get_provider_name() == "openrouter"
|
|
assert config.get_api_base() == "https://openrouter.ai/api/v1"
|
|
|
|
|
|
def test_openai_compat_provider_passes_model_through():
|
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
|
|
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
provider = OpenAICompatProvider(default_model="github-copilot/gpt-5.3-codex")
|
|
|
|
assert provider.get_default_model() == "github-copilot/gpt-5.3-codex"
|
|
|
|
|
|
def test_openai_codex_proxy_config_affects_provider_and_signature():
|
|
def config_with_proxy(proxy: str) -> Config:
|
|
return Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "openai-codex",
|
|
"model": "openai-codex/gpt-5.5",
|
|
}
|
|
},
|
|
"providers": {"openaiCodex": {"proxy": proxy}},
|
|
}
|
|
)
|
|
|
|
proxy = "http://127.0.0.1:23458"
|
|
config = config_with_proxy(proxy)
|
|
|
|
provider = make_provider(config)
|
|
|
|
assert provider.__class__.__name__ == "OpenAICodexProvider"
|
|
assert provider.proxy == proxy
|
|
assert provider_signature(config) != provider_signature(
|
|
config_with_proxy("http://127.0.0.1:23459")
|
|
)
|
|
|
|
|
|
def test_provider_proxy_rejects_unsupported_backend():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "anthropic",
|
|
"model": "anthropic/claude-opus-4-5",
|
|
}
|
|
},
|
|
"providers": {
|
|
"anthropic": {
|
|
"apiKey": "sk-test",
|
|
"proxy": "http://127.0.0.1:23458",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match=r"providers\.anthropic\.proxy"):
|
|
make_provider(config)
|
|
|
|
|
|
def test_github_copilot_provider_strips_prefixed_model_name():
|
|
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
|
|
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
provider = GitHubCopilotProvider(default_model="github-copilot/gpt-5.1")
|
|
|
|
kwargs = provider._build_kwargs(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=None,
|
|
model="github-copilot/gpt-5.1",
|
|
max_tokens=16,
|
|
temperature=0.1,
|
|
reasoning_effort=None,
|
|
tool_choice=None,
|
|
)
|
|
|
|
assert kwargs["model"] == "gpt-5.1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_copilot_provider_refreshes_client_api_key_before_chat():
|
|
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.api_key = "no-key"
|
|
mock_client.chat.completions.create = AsyncMock(return_value={
|
|
"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
})
|
|
|
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client):
|
|
provider = GitHubCopilotProvider(default_model="github-copilot/gpt-4")
|
|
await provider._ensure_client()
|
|
|
|
provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token")
|
|
|
|
response = await provider.chat(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
model="github-copilot/gpt-4",
|
|
max_tokens=16,
|
|
temperature=0.1,
|
|
)
|
|
|
|
assert response.content == "ok"
|
|
assert provider._client.api_key == "copilot-access-token"
|
|
provider._get_copilot_access_token.assert_awaited_once()
|
|
mock_client.chat.completions.create.assert_awaited_once()
|
|
|
|
|
|
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
|
assert _strip_model_prefix("openai-codex/gpt-5.6-sol") == "gpt-5.6-sol"
|
|
assert _strip_model_prefix("openai_codex/gpt-5.6-sol") == "gpt-5.6-sol"
|
|
|
|
|
|
def test_make_provider_passes_extra_headers_to_custom_provider():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}},
|
|
"providers": {
|
|
"custom": {
|
|
"apiKey": "test-key",
|
|
"apiBase": "https://example.com/v1",
|
|
"extraHeaders": {
|
|
"APP-Code": "demo-app",
|
|
"x-session-affinity": "sticky-session",
|
|
},
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
|
|
provider = make_provider(config)
|
|
asyncio.run(provider._ensure_client())
|
|
|
|
kwargs = mock_async_openai.call_args.kwargs
|
|
assert kwargs["api_key"] == "test-key"
|
|
assert kwargs["base_url"] == "https://example.com/v1"
|
|
assert kwargs["default_headers"]["APP-Code"] == "demo-app"
|
|
assert kwargs["default_headers"]["x-session-affinity"] == "sticky-session"
|
|
|
|
|
|
def test_make_provider_treats_dynamic_custom_provider_as_direct():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "my-company-api", "model": "gpt-4o-mini"}},
|
|
"providers": {
|
|
"my-company-api": {
|
|
"apiBase": "https://example.com/v1",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
|
|
provider = make_provider(config)
|
|
asyncio.run(provider._ensure_client())
|
|
|
|
assert provider.get_default_model() == "gpt-4o-mini"
|
|
assert provider._spec.name == "my_company_api"
|
|
assert provider._spec.is_direct is True
|
|
kwargs = mock_async_openai.call_args.kwargs
|
|
assert kwargs["api_key"] == "no-key"
|
|
assert kwargs["base_url"] == "https://example.com/v1"
|
|
|
|
|
|
def test_make_provider_strips_dynamic_custom_route_prefix_from_request_model():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "auto", "model": "my-company-api/gpt-4o-mini"}},
|
|
"providers": {
|
|
"my-company-api": {
|
|
"apiBase": "https://example.com/v1",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
provider = make_provider(config)
|
|
|
|
kwargs = provider._build_kwargs(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=None,
|
|
model=None,
|
|
max_tokens=16,
|
|
temperature=0.1,
|
|
reasoning_effort=None,
|
|
tool_choice=None,
|
|
)
|
|
body = provider._build_responses_body(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=None,
|
|
model=None,
|
|
max_tokens=16,
|
|
temperature=0.1,
|
|
reasoning_effort=None,
|
|
tool_choice=None,
|
|
)
|
|
|
|
assert config.get_provider_name() == "my-company-api"
|
|
assert kwargs["model"] == "gpt-4o-mini"
|
|
assert body["model"] == "gpt-4o-mini"
|
|
|
|
|
|
def test_make_provider_preserves_namespaced_model_for_forced_dynamic_provider():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "my-company-api",
|
|
"model": "openai/gpt-4o-mini",
|
|
}
|
|
},
|
|
"providers": {
|
|
"my-company-api": {
|
|
"apiBase": "https://example.com/v1",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
provider = make_provider(config)
|
|
kwargs = provider._build_kwargs(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=None,
|
|
model=None,
|
|
max_tokens=16,
|
|
temperature=0.1,
|
|
reasoning_effort=None,
|
|
tool_choice=None,
|
|
)
|
|
|
|
assert kwargs["model"] == "openai/gpt-4o-mini"
|
|
|
|
|
|
def test_make_provider_strips_dynamic_custom_route_prefix_once():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "auto",
|
|
"model": "my-company-api/openai/gpt-4o-mini",
|
|
}
|
|
},
|
|
"providers": {
|
|
"my-company-api": {
|
|
"apiBase": "https://example.com/v1",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
provider = make_provider(config)
|
|
kwargs = provider._build_kwargs(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=None,
|
|
model=None,
|
|
max_tokens=16,
|
|
temperature=0.1,
|
|
reasoning_effort=None,
|
|
tool_choice=None,
|
|
)
|
|
|
|
assert kwargs["model"] == "openai/gpt-4o-mini"
|
|
|
|
|
|
def test_make_provider_rejects_dynamic_custom_provider_without_api_base():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "my-company-api", "model": "gpt-4o-mini"}},
|
|
"providers": {
|
|
"my-company-api": {
|
|
"apiKey": "sk-test",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Provider 'my-company-api' requires api_base"):
|
|
make_provider(config)
|
|
|
|
|
|
def test_make_provider_rejects_auto_dynamic_custom_prefix_without_api_base():
|
|
config = Config.model_validate(
|
|
{
|
|
"agents": {"defaults": {"provider": "auto", "model": "companyProxy/gpt-4o"}},
|
|
"providers": {
|
|
"otherProxy": {
|
|
"apiBase": "https://other.example.test/v1",
|
|
},
|
|
"companyProxy": {
|
|
"apiKey": "sk-company",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Provider 'companyProxy' requires api_base"):
|
|
make_provider(config)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_agent_runtime(tmp_path):
|
|
"""Mock agent command dependencies for focused CLI tests."""
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
|
|
|
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
|
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
|
patch("nanobot.cli.agent.sync_workspace_templates") as mock_sync_templates, \
|
|
patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
|
|
patch("nanobot.cli.terminal._print_agent_response") as mock_print_response, \
|
|
patch("nanobot.bus.queue.MessageBus"), \
|
|
patch("nanobot.cron.service.CronService"), \
|
|
patch("nanobot.cli.agent.AgentLoop.from_config") as mock_from_config:
|
|
agent_loop = MagicMock()
|
|
agent_loop.channels_config = None
|
|
agent_loop.process_direct = AsyncMock(
|
|
return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"),
|
|
)
|
|
agent_loop.aclose = AsyncMock(return_value=None)
|
|
mock_from_config.return_value = agent_loop
|
|
|
|
yield {
|
|
"config": config,
|
|
"load_config": mock_load_config,
|
|
"sync_templates": mock_sync_templates,
|
|
"from_config": mock_from_config,
|
|
"agent_loop": agent_loop,
|
|
"print_response": mock_print_response,
|
|
}
|
|
|
|
|
|
def test_agent_help_shows_workspace_and_config_options():
|
|
result = runner.invoke(app, ["agent", "--help"])
|
|
|
|
assert result.exit_code == 0
|
|
stripped_output = _strip_ansi(result.stdout)
|
|
assert "--workspace" in stripped_output
|
|
assert "-w" in stripped_output
|
|
assert "--config" in stripped_output
|
|
assert "-c" in stripped_output
|
|
assert "--theme" in stripped_output
|
|
|
|
|
|
def test_agent_rejects_unknown_tui_theme(mock_agent_runtime):
|
|
result = runner.invoke(app, ["agent", "-m", "hello", "--theme", "sepia"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "must be auto, dark, or light" in result.output
|
|
mock_agent_runtime["from_config"].assert_not_called()
|
|
|
|
|
|
def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_runtime):
|
|
result = runner.invoke(app, ["agent", "-m", "hello"])
|
|
|
|
assert result.exit_code == 0
|
|
assert mock_agent_runtime["load_config"].call_args.args == (None,)
|
|
assert mock_agent_runtime["sync_templates"].call_args.args == (
|
|
mock_agent_runtime["config"].workspace_path,
|
|
)
|
|
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
|
assert passed_config.workspace_path == mock_agent_runtime["config"].workspace_path
|
|
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
|
|
mock_agent_runtime["print_response"].assert_called_once_with(
|
|
"mock-response", render_markdown=True, metadata={},
|
|
)
|
|
|
|
|
|
def test_agent_uses_explicit_config_path(mock_agent_runtime, tmp_path: Path):
|
|
config_path = tmp_path / "agent-config.json"
|
|
config_path.write_text("{}")
|
|
|
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)])
|
|
|
|
assert result.exit_code == 0
|
|
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
|
|
|
|
|
|
def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "instance" / "config.json"
|
|
config_file.parent.mkdir(parents=True)
|
|
config_file.write_text("{}")
|
|
|
|
config = Config()
|
|
seen: dict[str, Path] = {}
|
|
|
|
monkeypatch.setattr(
|
|
"nanobot.config.loader.set_config_path",
|
|
lambda path: seen.__setitem__("config_path", path),
|
|
)
|
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
|
|
|
|
class _FakeAgentLoop:
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
pass
|
|
|
|
async def process_direct(self, *_args, **_kwargs):
|
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None)
|
|
|
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["config_path"] == config_file.resolve()
|
|
|
|
|
|
def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "instance" / "config.json"
|
|
config_file.parent.mkdir(parents=True)
|
|
config_file.write_text("{}")
|
|
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "agent-workspace")
|
|
seen: dict[str, Path] = {}
|
|
|
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
|
|
|
class _FakeCron:
|
|
def __init__(self, store_path: Path) -> None:
|
|
seen["cron_store"] = store_path
|
|
|
|
class _FakeAgentLoop:
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
pass
|
|
|
|
async def process_direct(self, *_args, **_kwargs):
|
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None)
|
|
|
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json"
|
|
|
|
|
|
def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
config_file = tmp_path / "instance" / "config.json"
|
|
config_file.parent.mkdir(parents=True)
|
|
config_file.write_text("{}")
|
|
|
|
legacy_dir = tmp_path / "global" / "cron"
|
|
legacy_dir.mkdir(parents=True)
|
|
legacy_file = legacy_dir / "jobs.json"
|
|
legacy_file.write_text('{"jobs": []}')
|
|
|
|
override = tmp_path / "override-workspace"
|
|
config = Config()
|
|
seen: dict[str, Path] = {}
|
|
|
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
|
|
|
class _FakeCron:
|
|
def __init__(self, store_path: Path) -> None:
|
|
seen["cron_store"] = store_path
|
|
|
|
class _FakeAgentLoop:
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
pass
|
|
|
|
async def process_direct(self, *_args, **_kwargs):
|
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["agent", "-m", "hello", "-c", str(config_file), "-w", str(override)],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["cron_store"] == override / "cron" / "jobs.json"
|
|
assert legacy_file.exists()
|
|
assert not (override / "cron" / "jobs.json").exists()
|
|
|
|
|
|
def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
config_file = tmp_path / "instance" / "config.json"
|
|
config_file.parent.mkdir(parents=True)
|
|
config_file.write_text("{}")
|
|
|
|
legacy_dir = tmp_path / "global" / "cron"
|
|
legacy_dir.mkdir(parents=True)
|
|
legacy_file = legacy_dir / "jobs.json"
|
|
legacy_file.write_text('{"jobs": []}')
|
|
|
|
custom_workspace = tmp_path / "custom-workspace"
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(custom_workspace)
|
|
seen: dict[str, Path] = {}
|
|
|
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
|
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
|
|
|
class _FakeCron:
|
|
def __init__(self, store_path: Path) -> None:
|
|
seen["cron_store"] = store_path
|
|
|
|
class _FakeAgentLoop:
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
pass
|
|
|
|
async def process_direct(self, *_args, **_kwargs):
|
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
|
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.terminal._print_agent_response", lambda *_args, **_kwargs: None
|
|
)
|
|
|
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["cron_store"] == custom_workspace / "cron" / "jobs.json"
|
|
assert legacy_file.exists()
|
|
assert not (custom_workspace / "cron" / "jobs.json").exists()
|
|
|
|
|
|
def test_agent_overrides_workspace_path(mock_agent_runtime):
|
|
workspace_path = Path("/tmp/agent-workspace")
|
|
|
|
result = runner.invoke(app, ["agent", "-m", "hello", "-w", str(workspace_path)])
|
|
|
|
assert result.exit_code == 0
|
|
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
|
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
|
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
|
assert passed_config.workspace_path == workspace_path
|
|
|
|
|
|
def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path):
|
|
config_path = tmp_path / "agent-config.json"
|
|
config_path.write_text("{}")
|
|
workspace_path = Path("/tmp/agent-workspace")
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["agent", "-m", "hello", "-c", str(config_path), "-w", str(workspace_path)],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
|
|
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
|
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
|
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
|
assert passed_config.workspace_path == workspace_path
|
|
|
|
|
|
def test_heartbeat_retains_recent_messages_by_default():
|
|
config = Config()
|
|
|
|
assert config.gateway.heartbeat.keep_recent_messages == 8
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content, expected",
|
|
[
|
|
("", False),
|
|
("# Title\n\n## Active Tasks\n", False),
|
|
("<!--\nmulti-line\ncomment\n-->\n", False), # block comment, not tasks
|
|
("<!-- single line -->\n", False),
|
|
("## Active Tasks\n\n- water the plants\n", True),
|
|
("## Active Tasks\n\n### Garden\n\n- water the plants\n", True),
|
|
("## Notes\n\nsome random note\n", False),
|
|
("stray text before any heading\n## Active Tasks\n\n- task\n", True),
|
|
("stray text before any heading\n", False),
|
|
],
|
|
)
|
|
def test_heartbeat_has_active_tasks(content, expected):
|
|
from nanobot.cli.gateway_runtime import _heartbeat_has_active_tasks
|
|
|
|
assert _heartbeat_has_active_tasks(content) is expected
|
|
|
|
|
|
def test_heartbeat_skips_bundled_template():
|
|
from nanobot.cli.gateway_runtime import _heartbeat_has_active_tasks
|
|
from nanobot.utils.helpers import load_bundled_template
|
|
|
|
assert _heartbeat_has_active_tasks(load_bundled_template("HEARTBEAT.md")) is False
|
|
|
|
|
|
def test_heartbeat_target_skips_archived_webui_sessions():
|
|
from nanobot.cli.gateway_runtime import _pick_heartbeat_target_from_sessions
|
|
|
|
target = _pick_heartbeat_target_from_sessions(
|
|
enabled_channels=["websocket"],
|
|
archived_keys=["websocket:archived"],
|
|
sessions=[
|
|
{"key": "websocket:archived"},
|
|
{"key": "websocket:active"},
|
|
],
|
|
)
|
|
|
|
assert target == ("websocket", "active")
|
|
|
|
|
|
def test_heartbeat_target_uses_last_channel_for_unified_session():
|
|
from nanobot.cli.gateway_runtime import _pick_heartbeat_target_from_sessions
|
|
from nanobot.session.keys import LAST_CHANNEL_METADATA_KEY, UNIFIED_SESSION_KEY
|
|
|
|
target = _pick_heartbeat_target_from_sessions(
|
|
enabled_channels=["telegram", "discord"],
|
|
archived_keys=[],
|
|
sessions=[{"key": UNIFIED_SESSION_KEY}],
|
|
unified_session_metadata={LAST_CHANNEL_METADATA_KEY: "discord:chat-42"},
|
|
)
|
|
|
|
assert target == ("discord", "chat-42")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"metadata",
|
|
[
|
|
{"last_channel": "telegram:chat-42"},
|
|
{"last_channel": "cli:direct"},
|
|
{"last_channel": "invalid"},
|
|
],
|
|
)
|
|
def test_heartbeat_target_rejects_unroutable_unified_metadata(metadata):
|
|
from nanobot.cli.gateway_runtime import _pick_heartbeat_target_from_sessions
|
|
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
|
|
|
target = _pick_heartbeat_target_from_sessions(
|
|
enabled_channels=["discord"],
|
|
archived_keys=[],
|
|
sessions=[{"key": UNIFIED_SESSION_KEY}],
|
|
unified_session_metadata=metadata,
|
|
)
|
|
|
|
assert target == ("cli", "direct")
|
|
|
|
|
|
def _write_instance_config(tmp_path: Path) -> Path:
|
|
config_file = tmp_path / "instance" / "config.json"
|
|
config_file.parent.mkdir(parents=True)
|
|
config_file.write_text("{}")
|
|
return config_file
|
|
|
|
|
|
def _stop_gateway_provider(_config) -> object:
|
|
raise _StopGatewayError("stop")
|
|
|
|
|
|
def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapshot:
|
|
return ProviderSnapshot(
|
|
provider=provider,
|
|
model=config.agents.defaults.model,
|
|
context_window_tokens=config.agents.defaults.context_window_tokens,
|
|
signature=("test",),
|
|
)
|
|
|
|
|
|
def _patch_webui_provider_ready(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.factory.validate_provider_setup",
|
|
lambda _config: None,
|
|
)
|
|
|
|
|
|
def _patch_gateway_ports_free(monkeypatch) -> None:
|
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_a, **_kw: False)
|
|
monkeypatch.setattr("nanobot.cli.webui._tcp_endpoint_reachable", lambda *_a, **_kw: False)
|
|
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_a, **_kw: False)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.gateway_runtime._tcp_endpoint_reachable",
|
|
lambda *_a, **_kw: False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.gateway_runtime._webui_endpoint_reachable",
|
|
lambda *_a, **_kw: False,
|
|
)
|
|
|
|
|
|
def _record_gateway_lease_release(monkeypatch, captured: dict[str, object]) -> None:
|
|
from nanobot.gateway import GatewayClientLease
|
|
|
|
original_release = GatewayClientLease.release
|
|
|
|
def record_release(
|
|
lease: GatewayClientLease,
|
|
*,
|
|
timeout_s: int = 20,
|
|
wait_for_stop: bool = True,
|
|
) -> bool:
|
|
captured["lease_release_wait_for_stop"] = wait_for_stop
|
|
captured["dev_running_at_release"] = captured.get("dev_running")
|
|
return original_release(
|
|
lease,
|
|
timeout_s=timeout_s,
|
|
wait_for_stop=wait_for_stop,
|
|
)
|
|
|
|
monkeypatch.setattr(GatewayClientLease, "release", record_release)
|
|
|
|
|
|
def _patch_webui_managed_gateway(
|
|
monkeypatch,
|
|
seen: dict[str, object] | None = None,
|
|
) -> dict[str, object]:
|
|
"""Run WebUI CLI tests against a managed gateway without spawning a process."""
|
|
from nanobot.gateway import GatewayStatus, RuntimeResult
|
|
|
|
captured = seen if seen is not None else {}
|
|
_record_gateway_lease_release(monkeypatch, captured)
|
|
|
|
class _FakeRuntime:
|
|
def __init__(self, **kwargs) -> None:
|
|
captured["runtime_kwargs"] = kwargs
|
|
self.paths = kwargs["paths"]
|
|
self.running = False
|
|
|
|
def start_background(self, options):
|
|
captured["start_options"] = options
|
|
self.running = True
|
|
status = GatewayStatus(
|
|
running=True,
|
|
pid=123,
|
|
state_path=Path("gateway.json"),
|
|
log_path=Path("gateway.log"),
|
|
port=options.port,
|
|
reason="running",
|
|
)
|
|
return RuntimeResult(True, "gateway_started_background", status)
|
|
|
|
def start_on_demand(self, options):
|
|
from nanobot.gateway import GatewayClientLease
|
|
|
|
GatewayClientLease(self, kind="test-webui").mark_ephemeral()
|
|
return self.start_background(options)
|
|
|
|
def status(self):
|
|
return SimpleNamespace(running=self.running)
|
|
|
|
def stop(self, *, timeout_s: int):
|
|
captured["stop_timeout"] = timeout_s
|
|
self.running = False
|
|
return RuntimeResult(True, "gateway_stopped", self.status())
|
|
|
|
_stop = stop
|
|
|
|
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
|
|
lambda *_args, **_kwargs: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui._attach_to_background_gateway",
|
|
lambda runtime, **_kwargs: captured.__setitem__("attached_runtime", runtime),
|
|
)
|
|
return captured
|
|
|
|
|
|
def _patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config: Config,
|
|
*,
|
|
set_config_path=None,
|
|
sync_templates=None,
|
|
make_provider=None,
|
|
message_bus=None,
|
|
session_manager=None,
|
|
cron_service=None,
|
|
get_cron_dir=None,
|
|
) -> None:
|
|
provider_factory = make_provider or (lambda _config: _fake_provider())
|
|
|
|
monkeypatch.setattr(
|
|
"nanobot.config.loader.set_config_path",
|
|
set_config_path or (lambda _path: None),
|
|
)
|
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
|
monkeypatch.setattr("nanobot.config.loader.resolve_config_env_vars", lambda c: c)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.commands.sync_workspace_templates",
|
|
sync_templates or (lambda _path: None),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui.sync_workspace_templates",
|
|
sync_templates or (lambda _path: None),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.gateway_runtime.sync_workspace_templates",
|
|
sync_templates or (lambda _path: None),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.factory.make_provider",
|
|
provider_factory,
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.factory.build_provider_snapshot",
|
|
lambda _config: _test_provider_snapshot(provider_factory(_config), _config),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.factory.load_provider_snapshot",
|
|
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui_support._provider_setup_error",
|
|
lambda _config: None,
|
|
)
|
|
_patch_gateway_ports_free(monkeypatch)
|
|
|
|
if message_bus is not None:
|
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", message_bus)
|
|
if session_manager is not None:
|
|
monkeypatch.setattr("nanobot.session.manager.SessionManager", session_manager)
|
|
if cron_service is not None:
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", cron_service)
|
|
if get_cron_dir is not None:
|
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", get_cron_dir)
|
|
|
|
|
|
def test_heartbeat_empty_response_still_retains_recent_messages(
|
|
monkeypatch, tmp_path: Path,
|
|
) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
|
config.agents.defaults.dream.enabled = True
|
|
config.workspace_path.mkdir(parents=True)
|
|
(config.workspace_path / "HEARTBEAT.md").write_text(
|
|
"## Active Tasks\n\n- Check repository health\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
provider = _fake_provider()
|
|
bus = MagicMock()
|
|
bus.publish_outbound = AsyncMock()
|
|
seen: dict[str, object] = {}
|
|
|
|
class _FakeSession:
|
|
def retain_recent_legal_suffix(self, limit: int) -> None:
|
|
seen["retained_limit"] = limit
|
|
|
|
class _FakeSessionManager:
|
|
def __init__(self, _workspace: Path) -> None:
|
|
self.session = _FakeSession()
|
|
seen["heartbeat_session"] = self.session
|
|
|
|
def get_or_create(self, key: str) -> _FakeSession:
|
|
seen["session_key"] = key
|
|
return self.session
|
|
|
|
def save(self, session: _FakeSession) -> None:
|
|
seen["saved_session"] = session
|
|
|
|
def list_sessions(self) -> list[dict[str, str]]:
|
|
return [{"key": "telegram:u1"}]
|
|
|
|
class _FakeCron:
|
|
def __init__(self, _store_path: Path) -> None:
|
|
self.on_job = None
|
|
seen["cron"] = self
|
|
|
|
def status(self) -> dict[str, int]:
|
|
return {"jobs": 0}
|
|
|
|
def register_system_job(self, _job: CronJob) -> None:
|
|
raise _StopGatewayError("stop")
|
|
|
|
class _FakeAgentLoop(_GatewayAgentContractStub):
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
self.model = "test-model"
|
|
self.provider = kwargs.get("provider", object())
|
|
self.sessions = kwargs["session_manager"]
|
|
self.tools = {}
|
|
|
|
async def process_direct(self, *_args, **_kwargs):
|
|
return SimpleNamespace(content="")
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
async def run(self) -> None:
|
|
return None
|
|
|
|
def stop(self) -> None:
|
|
return None
|
|
|
|
class _FakeChannelManager:
|
|
def __init__(self, *_args, **_kwargs) -> None:
|
|
self.enabled_channels = ["telegram"]
|
|
|
|
async def _unexpected_evaluator(*_args, **_kwargs) -> bool:
|
|
raise AssertionError("empty heartbeat response must not be evaluated")
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
make_provider=lambda _config: provider,
|
|
message_bus=lambda: bus,
|
|
session_manager=_FakeSessionManager,
|
|
cron_service=_FakeCron,
|
|
)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.read_webui_sidebar_state", lambda: {})
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.evaluate_response", _unexpected_evaluator)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
cron = seen["cron"]
|
|
response = asyncio.run(cron.on_job(CronJob(id="heartbeat", name="heartbeat")))
|
|
|
|
assert response is None
|
|
assert seen["session_key"] == "heartbeat"
|
|
assert seen["retained_limit"] == config.gateway.heartbeat.keep_recent_messages
|
|
assert seen["saved_session"] is seen["heartbeat_session"]
|
|
|
|
|
|
def test_webui_yes_creates_config_and_enables_local_websocket(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config_file = tmp_path / "instance" / "config.json"
|
|
workspace = tmp_path / "workspace"
|
|
seen: dict[str, object] = {}
|
|
_patch_webui_provider_ready(monkeypatch)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui.sync_workspace_templates",
|
|
lambda path: seen.__setitem__("templates", path),
|
|
)
|
|
|
|
_patch_webui_managed_gateway(monkeypatch, seen)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"webui",
|
|
"--config",
|
|
str(config_file),
|
|
"--workspace",
|
|
str(workspace),
|
|
"--port",
|
|
"8899",
|
|
"--gateway-port",
|
|
"18888",
|
|
"--yes",
|
|
"--no-open",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
data = json.loads(config_file.read_text(encoding="utf-8"))
|
|
websocket = data["channels"]["websocket"]
|
|
assert websocket["enabled"] is True
|
|
assert websocket["host"] == "127.0.0.1"
|
|
assert websocket["port"] == 8899
|
|
assert websocket["websocketRequiresToken"] is True
|
|
assert isinstance(websocket["tokenIssueSecret"], str)
|
|
assert len(websocket["tokenIssueSecret"]) >= 32
|
|
assert data["agents"]["defaults"]["workspace"] == str(workspace)
|
|
assert seen["templates"] == workspace
|
|
options = seen["start_options"]
|
|
assert options.port == 18888
|
|
assert options.config_path == str(config_file.resolve(strict=False))
|
|
assert options.workspace == str(workspace.resolve(strict=False))
|
|
compact_output = re.sub(r"\s+", " ", _strip_ansi(result.stdout))
|
|
assert "bootstrap secret was generated" in compact_output
|
|
assert "channels.websocket.tokenIssueSecret" in compact_output
|
|
assert "rerun without --no-open" in compact_output
|
|
assert seen["lease_release_wait_for_stop"] is False
|
|
assert "stop_timeout" not in seen
|
|
|
|
|
|
def test_webui_background_points_to_the_single_persistent_gateway_command(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
workspace = tmp_path / "workspace"
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"webui",
|
|
"--background",
|
|
"--config",
|
|
str(config_file),
|
|
"--workspace",
|
|
str(workspace),
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
compact_output = _strip_ansi(result.stdout).replace("\n", " ")
|
|
assert "webui --background` no longer owns gateway lifecycle" in compact_output
|
|
assert "nanobot gateway --background --config" in compact_output
|
|
assert "--workspace" in compact_output
|
|
assert not config_file.exists()
|
|
|
|
|
|
def test_webui_dev_starts_vite_sidecar_and_gateway(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text("{}", encoding="utf-8")
|
|
seen: dict[str, object] = {}
|
|
_patch_webui_provider_ready(monkeypatch)
|
|
_patch_gateway_ports_free(monkeypatch)
|
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
|
|
|
@contextmanager
|
|
def fake_dev_server(**kwargs):
|
|
seen["dev_kwargs"] = kwargs
|
|
seen["dev_running"] = True
|
|
dev_server = SimpleNamespace(
|
|
url=kwargs["browser_url"],
|
|
ensure_running=lambda: None,
|
|
)
|
|
seen["dev_server"] = dev_server
|
|
try:
|
|
yield dev_server
|
|
finally:
|
|
seen["dev_running"] = False
|
|
|
|
monkeypatch.setattr("nanobot.cli.webui.run_webui_dev_server", fake_dev_server)
|
|
_patch_webui_managed_gateway(monkeypatch, seen)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui._attach_to_background_gateway",
|
|
lambda runtime, **kwargs: seen.update(
|
|
attached_runtime=runtime,
|
|
attach_kwargs=kwargs,
|
|
attached_while_dev_running=seen["dev_running"],
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui._open_webui_browser",
|
|
lambda url: seen.__setitem__("opened_url", url),
|
|
)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"webui",
|
|
"--dev",
|
|
"--config",
|
|
str(config_file),
|
|
"--port",
|
|
"8899",
|
|
"--gateway-port",
|
|
"18888",
|
|
"--yes",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
dev_kwargs = seen["dev_kwargs"]
|
|
assert isinstance(dev_kwargs, dict)
|
|
assert dev_kwargs["target_url"] == "http://127.0.0.1:8899"
|
|
browser_url = dev_kwargs["browser_url"]
|
|
assert isinstance(browser_url, str)
|
|
assert browser_url.startswith("http://127.0.0.1:5173/#/?bootstrapSecret=")
|
|
assert seen["start_options"].port == 18888
|
|
assert seen["attached_while_dev_running"] is True
|
|
assert seen["attach_kwargs"] == {"poll_hook": seen["dev_server"].ensure_running}
|
|
assert seen["opened_url"] == browser_url
|
|
assert seen["dev_running"] is False
|
|
assert seen["dev_running_at_release"] is False
|
|
assert seen["lease_release_wait_for_stop"] is False
|
|
assert "WebUI dev: http://127.0.0.1:5173/#/?bootstrapSecret=<redacted>" in re.sub(
|
|
r"\s+", " ", _strip_ansi(result.stdout)
|
|
)
|
|
|
|
|
|
def test_webui_dev_waits_for_external_gateway_via_health_endpoint(monkeypatch) -> None:
|
|
health_results = iter((True, False))
|
|
health_calls: list[tuple[str, int]] = []
|
|
sidecar_checks = 0
|
|
|
|
def fake_health(host: str, port: int) -> bool:
|
|
health_calls.append((host, port))
|
|
return next(health_results)
|
|
|
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", fake_health)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui._webui_endpoint_reachable",
|
|
lambda _url: pytest.fail("must not probe the WebSocket endpoint while waiting"),
|
|
)
|
|
monkeypatch.setattr("time.sleep", lambda _seconds: None)
|
|
|
|
def ensure_sidecar_running() -> None:
|
|
nonlocal sidecar_checks
|
|
sidecar_checks += 1
|
|
|
|
dev_server = MagicMock()
|
|
dev_server.ensure_running.side_effect = ensure_sidecar_running
|
|
cli_webui._wait_with_existing_foreground_gateway("127.0.0.1", 18888, dev_server)
|
|
|
|
assert health_calls == [("127.0.0.1", 18888), ("127.0.0.1", 18888)]
|
|
assert sidecar_checks == 2
|
|
|
|
|
|
async def test_webui_dev_monitor_fails_when_sidecar_exits() -> None:
|
|
dev_server = MagicMock()
|
|
dev_server.ensure_running.side_effect = WebUIDevError(
|
|
"WebUI development server exited unexpectedly (code 23)"
|
|
)
|
|
|
|
with pytest.raises(WebUIDevError, match=r"exited unexpectedly \(code 23\)"):
|
|
await cli_gateway_runtime._watch_webui_dev_server(
|
|
dev_server,
|
|
asyncio.Event(),
|
|
poll_interval_s=0,
|
|
)
|
|
|
|
|
|
async def test_webui_dev_monitor_ignores_an_expected_gateway_shutdown() -> None:
|
|
dev_server = MagicMock()
|
|
shutdown_event = asyncio.Event()
|
|
shutdown_event.set()
|
|
|
|
await cli_gateway_runtime._watch_webui_dev_server(
|
|
dev_server,
|
|
shutdown_event,
|
|
poll_interval_s=0,
|
|
)
|
|
|
|
dev_server.ensure_running.assert_not_called()
|
|
|
|
|
|
def test_browser_readiness_accepts_http_auth_response(monkeypatch) -> None:
|
|
def auth_required(*_args, **_kwargs):
|
|
raise urllib.error.HTTPError(
|
|
"http://127.0.0.1:8765/webui/bootstrap",
|
|
401,
|
|
"authentication required",
|
|
hdrs=None,
|
|
fp=None,
|
|
)
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", auth_required)
|
|
|
|
assert cli_gateway_runtime._http_endpoint_responding(
|
|
"http://127.0.0.1:8765/webui/bootstrap"
|
|
) is True
|
|
|
|
|
|
def test_browser_readiness_rejects_connection_error(monkeypatch) -> None:
|
|
def unavailable(*_args, **_kwargs):
|
|
raise urllib.error.URLError("connection refused")
|
|
|
|
monkeypatch.setattr("urllib.request.urlopen", unavailable)
|
|
|
|
assert cli_gateway_runtime._http_endpoint_responding(
|
|
"http://127.0.0.1:8765/webui/bootstrap"
|
|
) is False
|
|
|
|
|
|
def test_webui_yes_starts_first_run_without_provider_setup(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
seen: dict[str, object] = {}
|
|
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui_support._provider_setup_error",
|
|
lambda _config: "No API key configured for provider 'custom'.",
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui._provider_setup_error",
|
|
lambda _config: "No API key configured for provider 'custom'.",
|
|
)
|
|
_patch_gateway_ports_free(monkeypatch)
|
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
|
_patch_webui_managed_gateway(monkeypatch, seen)
|
|
|
|
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
|
|
|
|
assert result.exit_code == 0
|
|
assert config_file.exists()
|
|
assert seen["start_options"].config_path == str(config_file.resolve(strict=False))
|
|
assert "Configure a provider and model in WebUI Settings → Models." in result.stdout
|
|
|
|
|
|
def test_webui_missing_runtime_env_fails_before_starting_gateway(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
missing_env = "NANOBOT_TEST_MISSING_WEBUI_SECRET"
|
|
monkeypatch.delenv(missing_env, raising=False)
|
|
config_file.write_text(
|
|
json.dumps({
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "ollama",
|
|
"model": "ollama/qwen3",
|
|
}
|
|
},
|
|
"channels": {
|
|
"websocket": {
|
|
"enabled": True,
|
|
"host": "0.0.0.0",
|
|
"tokenIssueSecret": f"${{{missing_env}}}",
|
|
}
|
|
},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
|
|
|
|
assert result.exit_code == 1
|
|
assert missing_env in result.stdout
|
|
assert "nanobot status --config" in result.stdout
|
|
assert config_file.name in result.stdout
|
|
assert "Traceback" not in result.stdout
|
|
assert f"${{{missing_env}}}" in config_file.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_webui_yes_still_refuses_invalid_custom_model_setup(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text(
|
|
json.dumps({
|
|
"agents": {
|
|
"defaults": {
|
|
"provider": "custom",
|
|
"model": "custom/test-model",
|
|
}
|
|
},
|
|
"providers": {
|
|
"custom": {
|
|
"displayName": "Custom",
|
|
}
|
|
},
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "provider/model setup is incomplete" in result.stdout
|
|
assert "Settings → Models" in _without_rendered_line_breaks(result.stdout)
|
|
assert "nanobot onboard --wizard" in result.stdout
|
|
assert "nanobot status --config" in result.stdout
|
|
assert config_file.name in result.stdout
|
|
|
|
|
|
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
|
|
opened: list[str] = []
|
|
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
|
|
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
|
|
|
|
cli_webui_support._open_webui_browser(url, wait=False)
|
|
|
|
assert opened == [url]
|
|
output = _strip_ansi(capsys.readouterr().out)
|
|
assert "bootstrapSecret=<redacted>" in output
|
|
assert "super-secret" not in output
|
|
|
|
|
|
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text("{}")
|
|
seen: dict[str, object] = {}
|
|
_record_gateway_lease_release(monkeypatch, seen)
|
|
_patch_webui_provider_ready(monkeypatch)
|
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui_support._gateway_health_ready",
|
|
lambda *_args, **_kwargs: True,
|
|
)
|
|
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui._open_webui_browser",
|
|
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
|
|
)
|
|
class _FakeRuntime:
|
|
def __init__(self, **kwargs) -> None:
|
|
seen["runtime_kwargs"] = kwargs
|
|
self.paths = kwargs["paths"]
|
|
self.status_calls = 0
|
|
|
|
def status(self):
|
|
self.status_calls += 1
|
|
# The command checks during config refresh and once more before
|
|
# delegating to the attach helper.
|
|
# If that helper is not patched as intended, make its polling loop
|
|
# terminate instead of hanging the Windows test worker forever.
|
|
return SimpleNamespace(running=self.status_calls <= 2)
|
|
|
|
def start_background(self, options):
|
|
return SimpleNamespace(
|
|
ok=False,
|
|
message="gateway_already_running",
|
|
status=SimpleNamespace(log_path=self.paths.log_path),
|
|
)
|
|
|
|
start_on_demand = start_background
|
|
|
|
def restart(self, options, *, timeout_s: int):
|
|
seen["restart_options"] = options
|
|
seen["restart_timeout"] = timeout_s
|
|
return SimpleNamespace(
|
|
ok=True,
|
|
message="gateway_started_background",
|
|
status=SimpleNamespace(log_path=self.paths.log_path),
|
|
)
|
|
|
|
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui._attach_to_background_gateway",
|
|
lambda runtime: seen.__setitem__("attached_runtime", runtime),
|
|
)
|
|
|
|
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Gateway is already running; attaching to the existing WebUI" in result.stdout
|
|
assert isinstance(seen["attached_runtime"], _FakeRuntime)
|
|
opened_url = seen["opened_url"]
|
|
assert isinstance(opened_url, str)
|
|
parsed = urlparse(opened_url)
|
|
assert f"{parsed.scheme}://{parsed.netloc}" == "http://127.0.0.1:8765"
|
|
fragment = parsed.fragment.removeprefix("/?")
|
|
assert parse_qs(fragment).get("bootstrapSecret")
|
|
assert seen["open_kwargs"] == {"wait": False}
|
|
assert seen["lease_release_wait_for_stop"] is False
|
|
|
|
|
|
def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None:
|
|
stopped = False
|
|
|
|
class _FakeRuntime:
|
|
def status(self):
|
|
return SimpleNamespace(running=True)
|
|
|
|
def stop(self):
|
|
nonlocal stopped
|
|
stopped = True
|
|
return SimpleNamespace(ok=True, message="gateway_stopped")
|
|
|
|
def _interrupt(_seconds: float) -> None:
|
|
raise KeyboardInterrupt
|
|
|
|
cli_webui_support._attach_to_background_gateway(_FakeRuntime(), sleep=_interrupt)
|
|
|
|
assert stopped is False
|
|
output = capsys.readouterr().out
|
|
rendered = " ".join(output.split())
|
|
assert "Closing the browser does not stop channels or automations" in rendered
|
|
assert "gateway stops only when the last local client exits" in rendered
|
|
assert "WebUI launcher detached" in rendered
|
|
|
|
|
|
def test_attach_to_background_gateway_checks_owned_sidecar() -> None:
|
|
class _FakeRuntime:
|
|
def status(self):
|
|
return SimpleNamespace(running=True)
|
|
|
|
def sidecar_exited() -> None:
|
|
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")
|
|
|
|
with pytest.raises(WebUIDevError, match=r"exited unexpectedly \(code 23\)"):
|
|
cli_webui_support._attach_to_background_gateway(
|
|
_FakeRuntime(),
|
|
poll_hook=sidecar_exited,
|
|
)
|
|
|
|
|
|
def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text("{}")
|
|
_patch_webui_provider_ready(monkeypatch)
|
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args: True)
|
|
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args: True)
|
|
monkeypatch.setattr("nanobot.cli.webui._open_webui_browser", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui._attach_to_background_gateway",
|
|
lambda _runtime: pytest.fail("unmanaged gateway must not be attached"),
|
|
)
|
|
|
|
class _FakeRuntime:
|
|
def __init__(self, **kwargs) -> None:
|
|
self.paths = kwargs["paths"]
|
|
|
|
def status(self):
|
|
return SimpleNamespace(running=False)
|
|
|
|
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
|
|
|
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "controlled by another foreground command" in result.stdout
|
|
|
|
|
|
def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text("{}")
|
|
_patch_webui_provider_ready(monkeypatch)
|
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: False)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui_support._gateway_health_ready",
|
|
lambda *_args, **_kwargs: False,
|
|
)
|
|
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr("nanobot.cli.webui._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
|
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "nanobot cannot start because one of its local ports is already in use" in result.stdout
|
|
assert "--port" in result.stdout
|
|
assert "--gateway-port" in result.stdout
|
|
|
|
|
|
def test_webui_foreground_reports_an_existing_gateway_without_leaking_secret(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text(
|
|
'{"channels":{"websocket":{"tokenIssueSecret":"do-not-leak"}}}',
|
|
encoding="utf-8",
|
|
)
|
|
_patch_webui_provider_ready(monkeypatch)
|
|
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: True)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.webui_support._gateway_health_ready",
|
|
lambda *_args, **_kwargs: True,
|
|
)
|
|
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: False)
|
|
|
|
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "gateway is already running for this local instance" in result.stdout
|
|
assert "bootstrapSecret=<redacted>" in result.stdout
|
|
assert "do-not-leak" not in result.stdout
|
|
|
|
|
|
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
|
|
pytest.importorskip("aiohttp")
|
|
|
|
class _FakeApiApp:
|
|
def __init__(self) -> None:
|
|
self.on_startup: list[object] = []
|
|
self.on_cleanup: list[object] = []
|
|
|
|
class _FakeAgentLoop:
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(workspace=config.workspace_path, **extra)
|
|
def __init__(self, **kwargs) -> None:
|
|
seen["workspace"] = kwargs["workspace"]
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
def _fake_create_app(
|
|
agent_loop,
|
|
model_name: str,
|
|
request_timeout: float,
|
|
api_key: str = "",
|
|
prepare_agent=None,
|
|
):
|
|
seen["agent_loop"] = agent_loop
|
|
seen["model_name"] = model_name
|
|
seen["request_timeout"] = request_timeout
|
|
seen["api_key"] = api_key
|
|
seen["prepare_agent"] = prepare_agent
|
|
return _FakeApiApp()
|
|
|
|
def _fake_run_app(api_app, host: str, port: int, print):
|
|
seen["api_app"] = api_app
|
|
seen["host"] = host
|
|
seen["port"] = port
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
message_bus=lambda: object(),
|
|
session_manager=lambda _workspace: object(),
|
|
)
|
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
|
monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app)
|
|
|
|
|
|
def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
|
seen: dict[str, Path] = {}
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
set_config_path=lambda path: seen.__setitem__("config_path", path),
|
|
sync_templates=lambda path: seen.__setitem__("workspace", path),
|
|
make_provider=_stop_gateway_provider,
|
|
)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
assert seen["config_path"] == config_file.resolve()
|
|
assert seen["workspace"] == Path(config.agents.defaults.workspace)
|
|
|
|
|
|
def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
|
override = tmp_path / "override-workspace"
|
|
seen: dict[str, Path] = {}
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
sync_templates=lambda path: seen.__setitem__("workspace", path),
|
|
make_provider=_stop_gateway_provider,
|
|
)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["gateway", "--config", str(config_file), "--workspace", str(override)],
|
|
)
|
|
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
assert seen["workspace"] == override
|
|
assert config.workspace_path == override
|
|
|
|
|
|
def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
|
seen: dict[str, Path] = {}
|
|
|
|
class _StopCron:
|
|
def __init__(self, store_path: Path) -> None:
|
|
seen["cron_store"] = store_path
|
|
raise _StopGatewayError("stop")
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
message_bus=lambda: object(),
|
|
session_manager=lambda _workspace: object(),
|
|
cron_service=_StopCron,
|
|
)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json"
|
|
|
|
|
|
def test_gateway_unbound_agent_cron_is_skipped(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
config_file = tmp_path / "instance" / "config.json"
|
|
config_file.parent.mkdir(parents=True)
|
|
config_file.write_text("{}")
|
|
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
|
provider = _fake_provider()
|
|
bus = MagicMock()
|
|
bus.publish_outbound = AsyncMock()
|
|
seen: dict[str, object] = {}
|
|
|
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
|
monkeypatch.setattr("nanobot.cli.webui_support._provider_setup_error", lambda _config: None)
|
|
_patch_gateway_ports_free(monkeypatch)
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.factory.build_provider_snapshot",
|
|
lambda _config: _test_provider_snapshot(provider, _config),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.factory.load_provider_snapshot",
|
|
lambda _config_path=None: _test_provider_snapshot(provider, config),
|
|
)
|
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
|
|
|
class _FakeSession:
|
|
def __init__(self) -> None:
|
|
self.messages = []
|
|
|
|
def add_message(self, role: str, content: str, **kwargs) -> None:
|
|
self.messages.append({"role": role, "content": content, **kwargs})
|
|
|
|
class _FakeSessionManager:
|
|
def __init__(self, _workspace: Path) -> None:
|
|
self.session = _FakeSession()
|
|
seen["session_manager"] = self
|
|
|
|
def get_or_create(self, key: str) -> _FakeSession:
|
|
seen["session_key"] = key
|
|
return self.session
|
|
|
|
def save(self, session: _FakeSession) -> None:
|
|
seen["saved_session"] = session
|
|
|
|
monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager)
|
|
|
|
class _FakeCron:
|
|
def __init__(self, _store_path: Path) -> None:
|
|
self.on_job = None
|
|
seen["cron"] = self
|
|
|
|
class _FakeAgentLoop(_GatewayAgentContractStub):
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
self.model = "test-model"
|
|
self.provider = kwargs.get("provider", object())
|
|
self.tools = {}
|
|
seen["agent"] = self
|
|
|
|
async def process_direct(self, *_args, **_kwargs):
|
|
raise AssertionError("unbound cron job must not use process_direct")
|
|
|
|
async def submit_cron_turn(self, _msg: InboundMessage):
|
|
raise AssertionError("unbound cron job must not run as a bound cron turn")
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
async def run(self) -> None:
|
|
return None
|
|
|
|
def stop(self) -> None:
|
|
return None
|
|
|
|
class _StopAfterCronSetup:
|
|
def __init__(self, *_args, **_kwargs) -> None:
|
|
raise _StopGatewayError("stop")
|
|
|
|
async def _capture_evaluate_response(
|
|
*_args,
|
|
**_kwargs,
|
|
) -> bool:
|
|
raise AssertionError("unbound cron job must not be evaluated for delivery")
|
|
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.gateway_runtime.evaluate_response",
|
|
_capture_evaluate_response,
|
|
)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
cron = seen["cron"]
|
|
assert isinstance(cron, _FakeCron)
|
|
assert cron.on_job is not None
|
|
|
|
runtime_provider = object()
|
|
agent = seen["agent"]
|
|
agent.provider = runtime_provider
|
|
agent.model = "runtime-model"
|
|
|
|
job = CronJob(
|
|
id="cron-1",
|
|
name="stretch",
|
|
payload=CronPayload(
|
|
message="Remind me to stretch.",
|
|
deliver=True,
|
|
channel="telegram",
|
|
to="user-1",
|
|
),
|
|
)
|
|
|
|
with pytest.raises(CronJobSkippedError, match="unbound agent cron job"):
|
|
asyncio.run(cron.on_job(job))
|
|
|
|
bus.publish_outbound.assert_not_awaited()
|
|
|
|
|
|
def test_gateway_bound_cron_runs_as_session_turn(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
config_file = tmp_path / "instance" / "config.json"
|
|
config_file.parent.mkdir(parents=True)
|
|
config_file.write_text("{}")
|
|
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
|
provider = _fake_provider()
|
|
bus = MagicMock()
|
|
bus.publish_outbound = AsyncMock()
|
|
seen: dict[str, object] = {"run_records": []}
|
|
|
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.sync_workspace_templates", lambda _path: None)
|
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
|
monkeypatch.setattr("nanobot.cli.webui_support._provider_setup_error", lambda _config: None)
|
|
_patch_gateway_ports_free(monkeypatch)
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.factory.build_provider_snapshot",
|
|
lambda _config: _test_provider_snapshot(provider, _config),
|
|
)
|
|
monkeypatch.setattr(
|
|
"nanobot.providers.factory.load_provider_snapshot",
|
|
lambda _config_path=None: _test_provider_snapshot(provider, config),
|
|
)
|
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: bus)
|
|
|
|
class _FakeSessionManager:
|
|
def __init__(self, _workspace: Path) -> None:
|
|
pass
|
|
|
|
monkeypatch.setattr("nanobot.session.manager.SessionManager", _FakeSessionManager)
|
|
|
|
class _FakeCron:
|
|
def __init__(self, _store_path: Path) -> None:
|
|
self.on_job = None
|
|
seen["cron"] = self
|
|
|
|
def write_run_record(self, run_id: str, record: dict[str, object]) -> None:
|
|
seen["run_records"].append((run_id, record))
|
|
|
|
class _FakeAgentLoop(_GatewayAgentContractStub):
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
self.model = "test-model"
|
|
self.provider = kwargs.get("provider", object())
|
|
self.tools = {}
|
|
seen["agent"] = self
|
|
|
|
async def submit_cron_turn(self, msg: InboundMessage):
|
|
seen["cron_msg"] = msg
|
|
return OutboundMessage(
|
|
channel=msg.channel,
|
|
chat_id=msg.chat_id,
|
|
content="Checked the repo.",
|
|
)
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
async def run(self) -> None:
|
|
return None
|
|
|
|
def stop(self) -> None:
|
|
return None
|
|
|
|
class _StopAfterCronSetup:
|
|
def __init__(self, *_args, **_kwargs) -> None:
|
|
raise _StopGatewayError("stop")
|
|
|
|
async def _unexpected_evaluator(*_args, **_kwargs) -> bool:
|
|
raise AssertionError("bound cron must not use legacy response evaluator")
|
|
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.evaluate_response", _unexpected_evaluator)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
|
|
cron = seen["cron"]
|
|
job = CronJob(
|
|
id="repo-check",
|
|
name="Repo check",
|
|
payload=CronPayload(
|
|
message="Check repository health.",
|
|
session_key="websocket:chat-1",
|
|
origin_channel="websocket",
|
|
origin_chat_id="chat-1",
|
|
),
|
|
)
|
|
|
|
response = asyncio.run(cron.on_job(job))
|
|
|
|
assert response == "Checked the repo."
|
|
msg = seen["cron_msg"]
|
|
assert isinstance(msg, InboundMessage)
|
|
assert msg.channel == "websocket"
|
|
assert msg.chat_id == "chat-1"
|
|
assert msg.sender_id == "cron"
|
|
assert msg.session_key_override == "websocket:chat-1"
|
|
assert "Cron job: Check repository health." in msg.content
|
|
assert msg.metadata["webui"] is True
|
|
assert msg.metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] == {
|
|
"kind": "cron",
|
|
"label": "Repo check",
|
|
}
|
|
trigger = msg.metadata[CRON_TRIGGER_META]
|
|
assert trigger["job_id"] == "repo-check"
|
|
assert trigger["job_name"] == "Repo check"
|
|
assert trigger["persist_content"] == (
|
|
"Scheduled cron job triggered: Repo check\n\nCheck repository health."
|
|
)
|
|
assert msg.metadata[CRON_DEFER_UNTIL_IDLE_META] is True
|
|
statuses = [record["status"] for _run_id, record in seen["run_records"]]
|
|
assert statuses == ["queued", "ok"]
|
|
assert seen["run_records"][0][0] == seen["run_records"][1][0]
|
|
|
|
discord_job = CronJob(
|
|
id="thread-check",
|
|
name="Thread check",
|
|
payload=CronPayload(
|
|
message="Check the Discord thread.",
|
|
session_key="discord:456:thread:777",
|
|
origin_channel="discord",
|
|
origin_chat_id="777",
|
|
origin_metadata={
|
|
"context_chat_id": "456",
|
|
"parent_channel_id": "456",
|
|
"thread_id": "777",
|
|
},
|
|
),
|
|
)
|
|
|
|
response = asyncio.run(cron.on_job(discord_job))
|
|
|
|
assert response == "Checked the repo."
|
|
msg = seen["cron_msg"]
|
|
assert isinstance(msg, InboundMessage)
|
|
assert msg.channel == "discord"
|
|
assert msg.chat_id == "777"
|
|
assert msg.session_key_override == "discord:456:thread:777"
|
|
assert msg.metadata["context_chat_id"] == "456"
|
|
assert msg.metadata["parent_channel_id"] == "456"
|
|
assert msg.metadata["thread_id"] == "777"
|
|
|
|
telegram_job = CronJob(
|
|
id="telegram-topic",
|
|
name="Telegram topic",
|
|
payload=CronPayload(
|
|
message="Check the Telegram topic.",
|
|
session_key="telegram:-100123:topic:42",
|
|
origin_channel="telegram",
|
|
origin_chat_id="-100123",
|
|
origin_metadata={"message_thread_id": 42},
|
|
),
|
|
)
|
|
|
|
response = asyncio.run(cron.on_job(telegram_job))
|
|
|
|
assert response == "Checked the repo."
|
|
msg = seen["cron_msg"]
|
|
assert isinstance(msg, InboundMessage)
|
|
assert msg.channel == "telegram"
|
|
assert msg.chat_id == "-100123"
|
|
assert msg.session_key_override == "telegram:-100123:topic:42"
|
|
assert msg.metadata["message_thread_id"] == 42
|
|
|
|
feishu_job = CronJob(
|
|
id="feishu-topic",
|
|
name="Feishu topic",
|
|
payload=CronPayload(
|
|
message="Check the Feishu topic.",
|
|
session_key="feishu:oc_abc:om_root123",
|
|
origin_channel="feishu",
|
|
origin_chat_id="oc_abc",
|
|
origin_metadata={
|
|
"chat_type": "group",
|
|
"message_id": "om_root123",
|
|
"thread_id": "om_root123",
|
|
},
|
|
),
|
|
)
|
|
|
|
response = asyncio.run(cron.on_job(feishu_job))
|
|
|
|
assert response == "Checked the repo."
|
|
msg = seen["cron_msg"]
|
|
assert isinstance(msg, InboundMessage)
|
|
assert msg.channel == "feishu"
|
|
assert msg.chat_id == "oc_abc"
|
|
assert msg.session_key_override == "feishu:oc_abc:om_root123"
|
|
assert msg.metadata["message_id"] == "om_root123"
|
|
assert msg.metadata["thread_id"] == "om_root123"
|
|
|
|
|
|
@pytest.mark.parametrize("setup_error", [None, "No API key configured"])
|
|
def test_gateway_local_trigger_queue_submits_agent_turns(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
setup_error: str | None,
|
|
) -> None:
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
|
config.agents.defaults.dream.enabled = False
|
|
config.gateway.heartbeat.enabled = False
|
|
bus = MagicMock()
|
|
seen: dict[str, object] = {}
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
message_bus=lambda: bus,
|
|
session_manager=lambda _workspace: _FakeSessionManager(),
|
|
cron_service=lambda _store_path: _FakeCronService(),
|
|
)
|
|
|
|
class _FakeMemory:
|
|
def get_latest_cursor(self) -> int:
|
|
return 0
|
|
|
|
def get_last_dream_cursor(self) -> int:
|
|
return 0
|
|
|
|
def set_last_dream_cursor(self, _cursor: int) -> None:
|
|
return None
|
|
|
|
class _FakeContext:
|
|
memory = _FakeMemory()
|
|
|
|
class _FakeSessionManager:
|
|
def flush_all(self) -> int:
|
|
return 0
|
|
|
|
def list_sessions(self) -> list[dict[str, object]]:
|
|
return []
|
|
|
|
class _FakeCronService:
|
|
def __init__(self) -> None:
|
|
self.on_job = None
|
|
|
|
async def start(self) -> None:
|
|
return None
|
|
|
|
def stop(self) -> None:
|
|
return None
|
|
|
|
def status(self) -> dict[str, int]:
|
|
return {"jobs": 0}
|
|
|
|
def register_system_job(self, _job) -> None:
|
|
return None
|
|
|
|
def remove_system_job(self, _job_id: str) -> bool:
|
|
return False
|
|
|
|
class _FakeAgentLoop(_GatewayAgentContractStub):
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
seen["agent_from_config_kwargs"] = extra
|
|
return cls(**extra)
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
self.model = "test-model"
|
|
self.provider = _fake_provider()
|
|
self.tools = {}
|
|
self.context = _FakeContext()
|
|
self.sessions = kwargs["session_manager"]
|
|
self.submit_local_trigger_turn = AsyncMock()
|
|
self.runtime_resolver = MagicMock()
|
|
seen["agent"] = self
|
|
|
|
def schedule_background(self, _coro) -> None:
|
|
return None
|
|
|
|
async def run(self) -> None:
|
|
self.runtime_resolver.invalidate.assert_called_once_with()
|
|
await asyncio.Event().wait()
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
def stop(self) -> None:
|
|
return None
|
|
|
|
class _FakeChannelManager:
|
|
enabled_channels: list[str] = []
|
|
|
|
def __init__(self, *_args, **_kwargs) -> None:
|
|
return None
|
|
|
|
def get_channel(self, name: str) -> object | None:
|
|
return object() if name == "websocket" else None
|
|
|
|
async def start_all(self) -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
async def stop_all(self) -> None:
|
|
return None
|
|
|
|
async def _fake_run_local_trigger_queue(**kwargs):
|
|
seen["local_trigger_queue_kwargs"] = kwargs
|
|
raise _StopGatewayError("stop")
|
|
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
|
monkeypatch.setattr(
|
|
"nanobot.triggers.local_runner.run_local_trigger_queue",
|
|
_fake_run_local_trigger_queue,
|
|
)
|
|
|
|
cli_commands._run_gateway(
|
|
config,
|
|
health_server_enabled=False,
|
|
unconfigured_provider_error=setup_error,
|
|
)
|
|
|
|
agent = seen["agent"]
|
|
agent_kwargs = seen["agent_from_config_kwargs"]
|
|
kwargs = seen["local_trigger_queue_kwargs"]
|
|
assert isinstance(agent_kwargs["provider"], UnconfiguredProvider) is bool(setup_error)
|
|
refreshed_snapshot = agent_kwargs["provider_snapshot_loader"]()
|
|
assert not isinstance(refreshed_snapshot.provider, UnconfiguredProvider)
|
|
assert "local_trigger_store" in agent_kwargs
|
|
assert kwargs["store"] is agent_kwargs["local_trigger_store"]
|
|
assert "bus" not in kwargs
|
|
assert kwargs["submit_turn"] is agent.submit_local_trigger_turn
|
|
assert kwargs["is_channel_enabled"]("websocket") is True
|
|
assert kwargs["is_channel_enabled"]("telegram") is False
|
|
turn_delivery_factory = agent_kwargs["turn_delivery_factory"]
|
|
assert isinstance(turn_delivery_factory, TurnDeliveryFactory)
|
|
assert turn_delivery_factory.bus is bus
|
|
assert isinstance(turn_delivery_factory.route_policy, WebuiTurnRoutePolicy)
|
|
assert turn_delivery_factory.route_policy.sessions is agent.sessions
|
|
|
|
|
|
def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
legacy_dir = tmp_path / "global" / "cron"
|
|
legacy_dir.mkdir(parents=True)
|
|
legacy_file = legacy_dir / "jobs.json"
|
|
legacy_file.write_text('{"jobs": []}')
|
|
|
|
override = tmp_path / "override-workspace"
|
|
config = Config()
|
|
seen: dict[str, Path] = {}
|
|
|
|
class _StopCron:
|
|
def __init__(self, store_path: Path) -> None:
|
|
seen["cron_store"] = store_path
|
|
raise _StopGatewayError("stop")
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
message_bus=lambda: object(),
|
|
session_manager=lambda _workspace: object(),
|
|
cron_service=_StopCron,
|
|
get_cron_dir=lambda: legacy_dir,
|
|
)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["gateway", "--config", str(config_file), "--workspace", str(override)],
|
|
)
|
|
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
assert seen["cron_store"] == override / "cron" / "jobs.json"
|
|
assert legacy_file.exists()
|
|
assert not (override / "cron" / "jobs.json").exists()
|
|
|
|
|
|
def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
legacy_dir = tmp_path / "global" / "cron"
|
|
legacy_dir.mkdir(parents=True)
|
|
legacy_file = legacy_dir / "jobs.json"
|
|
legacy_file.write_text('{"jobs": []}')
|
|
|
|
custom_workspace = tmp_path / "custom-workspace"
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(custom_workspace)
|
|
seen: dict[str, Path] = {}
|
|
|
|
class _StopCron:
|
|
def __init__(self, store_path: Path) -> None:
|
|
seen["cron_store"] = store_path
|
|
raise _StopGatewayError("stop")
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
message_bus=lambda: object(),
|
|
session_manager=lambda _workspace: object(),
|
|
cron_service=_StopCron,
|
|
get_cron_dir=lambda: legacy_dir,
|
|
)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
assert seen["cron_store"] == custom_workspace / "cron" / "jobs.json"
|
|
assert legacy_file.exists()
|
|
assert not (custom_workspace / "cron" / "jobs.json").exists()
|
|
|
|
|
|
def test_migrate_cron_store_moves_legacy_file(tmp_path: Path) -> None:
|
|
"""Legacy global jobs.json is moved into the workspace on first run."""
|
|
from nanobot.cli.runtime_config import _migrate_cron_store
|
|
|
|
legacy_dir = tmp_path / "global" / "cron"
|
|
legacy_dir.mkdir(parents=True)
|
|
legacy_file = legacy_dir / "jobs.json"
|
|
legacy_file.write_text('{"jobs": []}')
|
|
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
|
workspace_cron = config.workspace_path / "cron" / "jobs.json"
|
|
|
|
with patch("nanobot.config.paths.get_cron_dir", return_value=legacy_dir):
|
|
_migrate_cron_store(config)
|
|
|
|
assert workspace_cron.exists()
|
|
assert workspace_cron.read_text() == '{"jobs": []}'
|
|
assert not legacy_file.exists()
|
|
|
|
|
|
def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) -> None:
|
|
"""Migration does not overwrite an existing workspace cron store."""
|
|
from nanobot.cli.runtime_config import _migrate_cron_store
|
|
|
|
legacy_dir = tmp_path / "global" / "cron"
|
|
legacy_dir.mkdir(parents=True)
|
|
(legacy_dir / "jobs.json").write_text('{"old": true}')
|
|
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
|
workspace_cron = config.workspace_path / "cron" / "jobs.json"
|
|
workspace_cron.parent.mkdir(parents=True)
|
|
workspace_cron.write_text('{"new": true}')
|
|
|
|
with patch("nanobot.config.paths.get_cron_dir", return_value=legacy_dir):
|
|
_migrate_cron_store(config)
|
|
|
|
assert workspace_cron.read_text() == '{"new": true}'
|
|
|
|
|
|
def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.gateway.port = 18791
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
make_provider=_stop_gateway_provider,
|
|
)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
assert "port 18791" in result.stdout
|
|
|
|
|
|
def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.gateway.port = 18791
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
make_provider=_stop_gateway_provider,
|
|
)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18792"])
|
|
|
|
assert isinstance(result.exception, _StopGatewayError)
|
|
assert "port 18792" in result.stdout
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("host", "display_url", "warns_about_public_bind"),
|
|
[
|
|
("127.0.0.1", "http://127.0.0.1:18791/health", False),
|
|
("0.0.0.0", "http://127.0.0.1:18791/health", True),
|
|
],
|
|
)
|
|
def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
host: str,
|
|
display_url: str,
|
|
warns_about_public_bind: bool,
|
|
) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.gateway.host = host
|
|
config.gateway.port = 18791
|
|
captured: dict[str, object] = {}
|
|
|
|
class _FakeSessionManager:
|
|
def flush_all(self) -> int:
|
|
return 0
|
|
|
|
class _FakeAgentLoop(_GatewayAgentContractStub):
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
def __init__(self, **_kwargs) -> None:
|
|
self.model = "test-model"
|
|
self.provider = object()
|
|
self.sessions = _FakeSessionManager()
|
|
self.runtime_resolver = MagicMock()
|
|
|
|
def llm_runtime(self) -> None:
|
|
return None
|
|
|
|
async def run(self) -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
async def aclose(self) -> None:
|
|
return None
|
|
|
|
def stop(self) -> None:
|
|
return None
|
|
|
|
class _FakeChannelManager:
|
|
def __init__(self, _config, _bus, **_kwargs) -> None:
|
|
self.enabled_channels = ["telegram", "discord"]
|
|
|
|
async def start_all(self) -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
async def stop_all(self) -> None:
|
|
return None
|
|
|
|
class _FakeCronService:
|
|
def __init__(self, _store_path: Path) -> None:
|
|
self.on_job = None
|
|
|
|
async def start(self) -> None:
|
|
return None
|
|
|
|
def stop(self) -> None:
|
|
return None
|
|
|
|
def status(self) -> dict[str, int]:
|
|
return {"jobs": 0}
|
|
|
|
def register_system_job(self, _job) -> None:
|
|
return None
|
|
|
|
class _FakeServer:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
|
return False
|
|
|
|
async def serve_forever(self) -> None:
|
|
raise _StopGatewayError("stop")
|
|
|
|
async def _fake_start_server(handler, host: str, port: int):
|
|
captured["handler"] = handler
|
|
captured["host"] = host
|
|
captured["port"] = port
|
|
return _FakeServer()
|
|
|
|
class _FakeReader:
|
|
def __init__(self, payload: bytes) -> None:
|
|
self.payload = payload
|
|
|
|
async def read(self, _size: int) -> bytes:
|
|
return self.payload
|
|
|
|
class _FakeWriter:
|
|
def __init__(self) -> None:
|
|
self.output = b""
|
|
self.closed = False
|
|
|
|
def write(self, data: bytes) -> None:
|
|
self.output += data
|
|
|
|
async def drain(self) -> None:
|
|
return None
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
message_bus=lambda: object(),
|
|
session_manager=lambda _workspace: object(),
|
|
)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
|
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert captured["host"] == host
|
|
assert captured["port"] == 18791
|
|
assert f"Health endpoint: {display_url}" in result.stdout
|
|
assert ("unauthenticated health endpoint" in result.stdout) is warns_about_public_bind
|
|
assert ("may be reachable from other devices" in result.stdout) is warns_about_public_bind
|
|
assert ("listening on 0.0.0.0" in result.stdout) is warns_about_public_bind
|
|
|
|
health_handler = captured["handler"]
|
|
assert callable(health_handler)
|
|
|
|
def _call_handler(path: str) -> tuple[str, _FakeWriter]:
|
|
request = f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode()
|
|
writer = _FakeWriter()
|
|
asyncio.run(health_handler(_FakeReader(request), writer))
|
|
return writer.output.decode(), writer
|
|
|
|
root_response, root_writer = _call_handler("/")
|
|
assert root_writer.closed is True
|
|
assert "HTTP/1.0 404 Not Found" in root_response
|
|
assert "Connection: close" in root_response
|
|
assert root_response.endswith("\r\n\r\nNot Found")
|
|
|
|
health_response, health_writer = _call_handler("/health")
|
|
assert health_writer.closed is True
|
|
assert "HTTP/1.0 200 OK" in health_response
|
|
health_body = json.loads(health_response.split("\r\n\r\n", 1)[1])
|
|
assert health_body == {"status": "ok"}
|
|
|
|
missing_response, missing_writer = _call_handler("/missing")
|
|
assert missing_writer.closed is True
|
|
assert "HTTP/1.0 404 Not Found" in missing_response
|
|
assert missing_response.endswith("\r\n\r\nNot Found")
|
|
|
|
if host == "127.0.0.1":
|
|
async def _exercise_connection_limit() -> None:
|
|
release = asyncio.Event()
|
|
all_started = asyncio.Event()
|
|
started = 0
|
|
|
|
class _BlockingReader:
|
|
async def read(self, _size: int) -> bytes:
|
|
nonlocal started
|
|
started += 1
|
|
if started == cli_gateway_runtime._GATEWAY_HEALTH_MAX_CONNECTIONS:
|
|
all_started.set()
|
|
await release.wait()
|
|
return b"GET /health HTTP/1.1\r\n\r\n"
|
|
|
|
active_writers = [
|
|
_FakeWriter()
|
|
for _ in range(cli_gateway_runtime._GATEWAY_HEALTH_MAX_CONNECTIONS)
|
|
]
|
|
active_tasks = [
|
|
asyncio.create_task(health_handler(_BlockingReader(), writer))
|
|
for writer in active_writers
|
|
]
|
|
await asyncio.wait_for(all_started.wait(), timeout=1)
|
|
|
|
overflow_writer = _FakeWriter()
|
|
await health_handler(
|
|
_FakeReader(b"GET /health HTTP/1.1\r\n\r\n"),
|
|
overflow_writer,
|
|
)
|
|
assert overflow_writer.closed is True
|
|
assert overflow_writer.output == b""
|
|
|
|
release.set()
|
|
await asyncio.gather(*active_tasks)
|
|
|
|
asyncio.run(_exercise_connection_limit())
|
|
|
|
class _NeverRespondingReader:
|
|
async def read(self, _size: int) -> bytes:
|
|
await asyncio.Event().wait()
|
|
|
|
monkeypatch.setattr(
|
|
cli_gateway_runtime,
|
|
"_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS",
|
|
0.01,
|
|
)
|
|
timed_out_writer = _FakeWriter()
|
|
asyncio.run(health_handler(_NeverRespondingReader(), timed_out_writer))
|
|
assert timed_out_writer.closed is True
|
|
assert timed_out_writer.output == b""
|
|
|
|
|
|
def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.gateway.port = 18791
|
|
seen: dict[str, object] = {}
|
|
|
|
class _FakeSessionManager:
|
|
def flush_all(self) -> int:
|
|
return 0
|
|
|
|
class _FakeAgentLoop(_GatewayAgentContractStub):
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
|
|
def __init__(self, **_kwargs) -> None:
|
|
self.model = "test-model"
|
|
self.provider = object()
|
|
self.sessions = _FakeSessionManager()
|
|
self.runtime_resolver = MagicMock()
|
|
|
|
def llm_runtime(self) -> None:
|
|
return None
|
|
|
|
async def run(self) -> None:
|
|
seen["agent_task"] = asyncio.current_task()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
seen["agent_task_cleaned_up"] = True
|
|
|
|
async def aclose(self) -> None:
|
|
seen["agent_closed"] = True
|
|
|
|
def stop(self) -> None:
|
|
seen["agent_stopped"] = True
|
|
|
|
class _FakeMCPProvider:
|
|
def __init__(self) -> None:
|
|
self.connect_task: asyncio.Task | None = None
|
|
self.close_tasks: list[asyncio.Task | None] = []
|
|
|
|
@classmethod
|
|
def from_config(cls, _config, _registry):
|
|
provider = cls()
|
|
seen["mcp_provider"] = provider
|
|
return provider
|
|
|
|
async def connect(self) -> None:
|
|
self.connect_task = asyncio.current_task()
|
|
|
|
async def aclose(self) -> None:
|
|
self.close_tasks.append(asyncio.current_task())
|
|
|
|
def runtime_status(self) -> dict[str, str]:
|
|
return {}
|
|
|
|
async def reload(self) -> dict[str, object]:
|
|
return {"ok": True}
|
|
|
|
class _FakeChannelManager:
|
|
def __init__(self, _config, _bus, **_kwargs) -> None:
|
|
self.enabled_channels = ["telegram"]
|
|
|
|
async def start_all(self) -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
async def stop_all(self) -> None:
|
|
seen["channels_stopped"] = True
|
|
|
|
class _FakeCronService:
|
|
def __init__(self, _store_path: Path) -> None:
|
|
self.on_job = None
|
|
|
|
async def start(self) -> None:
|
|
return None
|
|
|
|
def stop(self) -> None:
|
|
seen["cron_stopped"] = True
|
|
|
|
def status(self) -> dict[str, int]:
|
|
return {"jobs": 0}
|
|
|
|
def register_system_job(self, _job) -> None:
|
|
return None
|
|
|
|
class _FakeServer:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
|
return False
|
|
|
|
async def serve_forever(self) -> None:
|
|
raise _StopGatewayError("stop")
|
|
|
|
async def _fake_start_server(_handler, _host: str, _port: int):
|
|
return _FakeServer()
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
message_bus=lambda: object(),
|
|
session_manager=lambda _workspace: object(),
|
|
)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider)
|
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
|
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["agent_stopped"] is True
|
|
assert seen["agent_closed"] is True
|
|
assert seen["agent_task_cleaned_up"] is True
|
|
assert seen["channels_stopped"] is True
|
|
assert seen["cron_stopped"] is True
|
|
mcp_provider = seen["mcp_provider"]
|
|
assert isinstance(mcp_provider, _FakeMCPProvider)
|
|
assert mcp_provider.connect_task is seen["agent_task"]
|
|
assert mcp_provider.close_tasks[0] is mcp_provider.connect_task
|
|
assert len(mcp_provider.close_tasks) == 2
|
|
|
|
|
|
def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.gateway.port = 18791
|
|
seen: dict[str, object] = {}
|
|
shutdown_order: list[str] = []
|
|
|
|
class _FakeSessionManager:
|
|
def flush_all(self) -> int:
|
|
return 0
|
|
|
|
class _FakeAgentLoop(_GatewayAgentContractStub):
|
|
@classmethod
|
|
def from_config(cls, config, bus=None, **extra):
|
|
return cls(**extra)
|
|
|
|
def __init__(self, **_kwargs) -> None:
|
|
self.model = "test-model"
|
|
self.provider = object()
|
|
self.sessions = _FakeSessionManager()
|
|
self.runtime_resolver = MagicMock()
|
|
|
|
def llm_runtime(self) -> None:
|
|
return None
|
|
|
|
async def run(self) -> None:
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
seen["agent_task_cleaned_up"] = True
|
|
|
|
async def aclose(self) -> None:
|
|
seen["agent_closed"] = True
|
|
|
|
def stop(self) -> None:
|
|
seen["agent_stopped"] = True
|
|
|
|
class _FakeChannelManager:
|
|
def __init__(self, _config, _bus, **_kwargs) -> None:
|
|
self.enabled_channels = ["websocket"]
|
|
|
|
async def start_all(self) -> None:
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
seen["channel_task_cleaned_up"] = True
|
|
shutdown_order.append("channel_task_cleaned_up")
|
|
|
|
async def stop_all(self) -> None:
|
|
seen["channels_stopped"] = True
|
|
shutdown_order.append("channels_stopped")
|
|
|
|
class _FakeCronService:
|
|
def __init__(self, _store_path: Path) -> None:
|
|
self.on_job = None
|
|
|
|
async def start(self) -> None:
|
|
return None
|
|
|
|
def stop(self) -> None:
|
|
seen["cron_stopped"] = True
|
|
|
|
def status(self) -> dict[str, int]:
|
|
return {"jobs": 0}
|
|
|
|
def register_system_job(self, _job) -> None:
|
|
return None
|
|
|
|
class _FakeServer:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
|
return False
|
|
|
|
async def serve_forever(self) -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
async def _fake_start_server(_handler, _host: str, _port: int):
|
|
return _FakeServer()
|
|
|
|
def _fake_install_shutdown_handlers(_loop, event, _tasks, _print_status):
|
|
async def _trigger_shutdown() -> None:
|
|
await asyncio.sleep(0)
|
|
event.set()
|
|
|
|
asyncio.create_task(_trigger_shutdown())
|
|
|
|
def _restore() -> None:
|
|
seen["shutdown_handlers_restored"] = True
|
|
|
|
return _restore
|
|
|
|
_patch_cli_command_runtime(
|
|
monkeypatch,
|
|
config,
|
|
message_bus=lambda: object(),
|
|
session_manager=lambda _workspace: object(),
|
|
)
|
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
|
monkeypatch.setattr("asyncio.start_server", _fake_start_server)
|
|
monkeypatch.setattr(
|
|
"nanobot.cli.gateway_runtime._install_gateway_shutdown_handlers",
|
|
_fake_install_shutdown_handlers,
|
|
)
|
|
|
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["agent_stopped"] is True
|
|
assert seen["agent_closed"] is True
|
|
assert seen["agent_task_cleaned_up"] is True
|
|
assert seen["channel_task_cleaned_up"] is True
|
|
assert seen["channels_stopped"] is True
|
|
assert seen["cron_stopped"] is True
|
|
assert seen["shutdown_handlers_restored"] is True
|
|
# Channel cleanup must run before cancellation drains the manager task.
|
|
# DingTalk's stream SDK can otherwise swallow cancellation and reconnect.
|
|
assert shutdown_order == ["channels_stopped", "channel_task_cleaned_up"]
|
|
|
|
|
|
def test_serve_uses_api_config_defaults_and_workspace_override(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
|
config.api.host = "127.0.0.2"
|
|
config.api.port = 18900
|
|
config.api.timeout = 45.0
|
|
config.api.api_key = "secret"
|
|
override_workspace = tmp_path / "override-workspace"
|
|
seen: dict[str, object] = {}
|
|
|
|
_patch_serve_runtime(monkeypatch, config, seen)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["serve", "--config", str(config_file), "--workspace", str(override_workspace)],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["workspace"] == override_workspace
|
|
assert seen["host"] == "127.0.0.2"
|
|
assert seen["port"] == 18900
|
|
assert seen["request_timeout"] == 45.0
|
|
assert seen["api_key"] == "secret"
|
|
|
|
|
|
def test_trigger_cli_queues_message_in_workspace(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from nanobot.triggers.local_store import LocalTriggerStore
|
|
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
|
_patch_cli_command_runtime(monkeypatch, config)
|
|
|
|
store = LocalTriggerStore(config.workspace_path)
|
|
trigger = store.create(
|
|
name="Review hook",
|
|
channel="websocket",
|
|
chat_id="chat-1",
|
|
session_key="websocket:chat-1",
|
|
)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["trigger", "--config", str(config_file), trigger.id, "Review PR #4502"],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert f"Queued {trigger.id}" in result.stdout
|
|
deliveries = store.claim_deliveries()
|
|
assert len(deliveries) == 1
|
|
assert deliveries[0].trigger_id == trigger.id
|
|
assert deliveries[0].content == "Review PR #4502"
|
|
|
|
|
|
def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.api.host = "127.0.0.2"
|
|
config.api.port = 18900
|
|
config.api.timeout = 45.0
|
|
config.api.api_key = "secret"
|
|
seen: dict[str, object] = {}
|
|
|
|
_patch_serve_runtime(monkeypatch, config, seen)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"serve",
|
|
"--config",
|
|
str(config_file),
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
"18901",
|
|
"--timeout",
|
|
"46",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["host"] == "127.0.0.1"
|
|
assert seen["port"] == 18901
|
|
assert seen["request_timeout"] == 46.0
|
|
assert seen["api_key"] == "secret"
|
|
|
|
|
|
def test_serve_allows_loopback_without_api_key(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
seen: dict[str, object] = {}
|
|
|
|
_patch_serve_runtime(monkeypatch, config, seen)
|
|
|
|
result = runner.invoke(app, ["serve", "--config", str(config_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["host"] == "127.0.0.1"
|
|
assert seen["api_key"] == ""
|
|
|
|
|
|
def test_serve_passes_configured_api_key(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
config.api.api_key = " secret "
|
|
seen: dict[str, object] = {}
|
|
|
|
_patch_serve_runtime(monkeypatch, config, seen)
|
|
|
|
result = runner.invoke(app, ["serve", "--config", str(config_file)])
|
|
|
|
assert result.exit_code == 0
|
|
assert seen["api_key"] == "secret"
|
|
|
|
|
|
def test_serve_rejects_wildcard_host_without_api_key(monkeypatch, tmp_path: Path) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
seen: dict[str, object] = {}
|
|
|
|
_patch_serve_runtime(monkeypatch, config, seen)
|
|
|
|
result = runner.invoke(app, ["serve", "--config", str(config_file), "--host", "0.0.0.0"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "api_key is not set" in result.stdout
|
|
assert "workspace" not in seen
|
|
assert "api_app" not in seen
|
|
|
|
|
|
def test_serve_rejects_specific_network_interface_without_api_key(
|
|
monkeypatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config_file = _write_instance_config(tmp_path)
|
|
config = Config()
|
|
seen: dict[str, object] = {}
|
|
|
|
_patch_serve_runtime(monkeypatch, config, seen)
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["serve", "--config", str(config_file), "--host", "192.168.1.10"],
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "api_key" in result.stdout
|
|
assert "prevent unauthenticated access" in result.stdout
|
|
assert "api_app" not in seen
|
|
|
|
|
|
def test_channels_login_requires_channel_name() -> None:
|
|
result = runner.invoke(app, ["channels", "login"])
|
|
|
|
assert result.exit_code == 2
|