Merge remote-tracking branch 'origin/main'
# Conflicts: # nanobot/cli/commands.py # tests/test_commands.py
This commit is contained in:
@@ -948,10 +948,14 @@ nanobot gateway
|
|||||||
|
|
||||||
Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
|
Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
|
||||||
|
|
||||||
**1. Install the optional dependency**
|
> Weixin support is available from source checkout, but is not included in the current PyPI release yet.
|
||||||
|
|
||||||
|
**1. Install from source**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install nanobot-ai[weixin]
|
git clone https://github.com/HKUDS/nanobot.git
|
||||||
|
cd nanobot
|
||||||
|
pip install -e ".[weixin]"
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Configure**
|
**2. Configure**
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from rich.text import Text
|
|||||||
|
|
||||||
from nanobot import __logo__, __version__
|
from nanobot import __logo__, __version__
|
||||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
||||||
from nanobot.config.paths import get_workspace_path
|
from nanobot.config.paths import get_workspace_path, is_default_workspace
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.utils.helpers import sync_workspace_templates
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
|
|
||||||
@@ -507,6 +507,17 @@ def _warn_deprecated_config_keys(config_path: Path | None) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_cron_store(config: "Config") -> None:
|
||||||
|
"""One-time migration: move legacy global cron store into the workspace."""
|
||||||
|
from nanobot.config.paths import get_cron_dir
|
||||||
|
|
||||||
|
legacy_path = get_cron_dir() / "jobs.json"
|
||||||
|
new_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
|
if legacy_path.is_file() and not new_path.exists():
|
||||||
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
import shutil
|
||||||
|
shutil.move(str(legacy_path), str(new_path))
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Gateway / Server
|
# Gateway / Server
|
||||||
@@ -525,7 +536,6 @@ def gateway(
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
from nanobot.config.loader import get_config_path
|
from nanobot.config.loader import get_config_path
|
||||||
from nanobot.config.paths import get_cron_dir
|
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.gateway.http import GatewayHttpServer
|
from nanobot.gateway.http import GatewayHttpServer
|
||||||
@@ -545,8 +555,12 @@ def gateway(
|
|||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
session_manager = SessionManager(config.workspace_path)
|
session_manager = SessionManager(config.workspace_path)
|
||||||
|
|
||||||
# Create cron service first (callback set after agent creation)
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||||
cron_store_path = get_cron_dir() / "jobs.json"
|
if is_default_workspace(config.workspace_path):
|
||||||
|
_migrate_cron_store(config)
|
||||||
|
|
||||||
|
# Create cron service with workspace-scoped store
|
||||||
|
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
@@ -732,7 +746,6 @@ def agent(
|
|||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.loader import get_config_path
|
from nanobot.config.loader import get_config_path
|
||||||
from nanobot.config.paths import get_cron_dir
|
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
config = _load_runtime_config(config, workspace)
|
config = _load_runtime_config(config, workspace)
|
||||||
@@ -741,8 +754,12 @@ def agent(
|
|||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
|
|
||||||
# Create cron service for tool usage (no callback needed for CLI unless running)
|
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||||
cron_store_path = get_cron_dir() / "jobs.json"
|
if is_default_workspace(config.workspace_path):
|
||||||
|
_migrate_cron_store(config)
|
||||||
|
|
||||||
|
# Create cron service with workspace-scoped store
|
||||||
|
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
if logs:
|
if logs:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from nanobot.config.paths import (
|
|||||||
get_cron_dir,
|
get_cron_dir,
|
||||||
get_data_dir,
|
get_data_dir,
|
||||||
get_legacy_sessions_dir,
|
get_legacy_sessions_dir,
|
||||||
|
is_default_workspace,
|
||||||
get_logs_dir,
|
get_logs_dir,
|
||||||
get_media_dir,
|
get_media_dir,
|
||||||
get_runtime_subdir,
|
get_runtime_subdir,
|
||||||
@@ -24,6 +25,7 @@ __all__ = [
|
|||||||
"get_cron_dir",
|
"get_cron_dir",
|
||||||
"get_logs_dir",
|
"get_logs_dir",
|
||||||
"get_workspace_path",
|
"get_workspace_path",
|
||||||
|
"is_default_workspace",
|
||||||
"get_cli_history_path",
|
"get_cli_history_path",
|
||||||
"get_bridge_install_dir",
|
"get_bridge_install_dir",
|
||||||
"get_legacy_sessions_dir",
|
"get_legacy_sessions_dir",
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ def get_workspace_path(workspace: str | None = None) -> Path:
|
|||||||
return ensure_dir(path)
|
return ensure_dir(path)
|
||||||
|
|
||||||
|
|
||||||
|
def is_default_workspace(workspace: str | Path | None) -> bool:
|
||||||
|
"""Return whether a workspace resolves to nanobot's default workspace path."""
|
||||||
|
current = Path(workspace).expanduser() if workspace is not None else Path.home() / ".nanobot" / "workspace"
|
||||||
|
default = Path.home() / ".nanobot" / "workspace"
|
||||||
|
return current.resolve(strict=False) == default.resolve(strict=False)
|
||||||
|
|
||||||
|
|
||||||
def get_cli_history_path() -> Path:
|
def get_cli_history_path() -> Path:
|
||||||
"""Return the shared CLI history file path."""
|
"""Return the shared CLI history file path."""
|
||||||
return Path.home() / ".nanobot" / "history" / "cli_history"
|
return Path.home() / ".nanobot" / "history" / "cli_history"
|
||||||
|
|||||||
@@ -584,12 +584,15 @@ class Config(BaseSettings):
|
|||||||
self, model: str | None = None
|
self, model: str | None = None
|
||||||
) -> tuple["ProviderConfig | None", str | None]:
|
) -> tuple["ProviderConfig | None", str | None]:
|
||||||
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS, find_by_name
|
||||||
|
|
||||||
forced = self.agents.defaults.provider
|
forced = self.agents.defaults.provider
|
||||||
if forced != "auto":
|
if forced != "auto":
|
||||||
p = getattr(self.providers, forced, None)
|
spec = find_by_name(forced)
|
||||||
return (p, forced) if p else (None, None)
|
if spec:
|
||||||
|
p = getattr(self.providers, spec.name, None)
|
||||||
|
return (p, spec.name) if p else (None, None)
|
||||||
|
return None, None
|
||||||
|
|
||||||
model_lower = (model or self.agents.defaults.model).lower()
|
model_lower = (model or self.agents.defaults.model).lower()
|
||||||
model_normalized = model_lower.replace("-", "_")
|
model_normalized = model_lower.replace("-", "_")
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic.alias_generators import to_snake
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProviderSpec:
|
class ProviderSpec:
|
||||||
@@ -544,7 +546,8 @@ def find_gateway(
|
|||||||
|
|
||||||
def find_by_name(name: str) -> ProviderSpec | None:
|
def find_by_name(name: str) -> ProviderSpec | None:
|
||||||
"""Find a provider spec by config field name, e.g. "dashscope"."""
|
"""Find a provider spec by config field name, e.g. "dashscope"."""
|
||||||
|
normalized = to_snake(name.replace("-", "_"))
|
||||||
for spec in PROVIDERS:
|
for spec in PROVIDERS:
|
||||||
if spec.name == name:
|
if spec.name == normalized:
|
||||||
return spec
|
return spec
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from nanobot.cli.commands import _make_provider, app
|
|||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||||
from nanobot.providers.registry import find_by_model
|
from nanobot.providers.registry import find_by_model, find_by_name
|
||||||
|
|
||||||
|
|
||||||
def _strip_ansi(text: str) -> str:
|
def _strip_ansi(text: str) -> str:
|
||||||
@@ -236,6 +236,34 @@ def test_config_explicit_ollama_provider_uses_default_localhost_api_base():
|
|||||||
assert config.get_api_base() == "http://localhost:11434"
|
assert config.get_api_base() == "http://localhost:11434"
|
||||||
|
|
||||||
|
|
||||||
|
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_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"
|
||||||
|
|
||||||
|
|
||||||
def test_config_auto_detects_ollama_from_local_api_base():
|
def test_config_auto_detects_ollama_from_local_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
@@ -329,10 +357,8 @@ def mock_agent_runtime(tmp_path):
|
|||||||
"""Mock agent command dependencies for focused CLI tests."""
|
"""Mock agent command dependencies for focused CLI tests."""
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
||||||
cron_dir = tmp_path / "data" / "cron"
|
|
||||||
|
|
||||||
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
||||||
patch("nanobot.config.paths.get_cron_dir", return_value=cron_dir), \
|
|
||||||
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
||||||
patch("nanobot.cli.commands._make_provider", return_value=object()), \
|
patch("nanobot.cli.commands._make_provider", return_value=object()), \
|
||||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
||||||
@@ -408,7 +434,6 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
lambda path: seen.__setitem__("config_path", path),
|
lambda path: seen.__setitem__("config_path", path),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron")
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
@@ -433,6 +458,147 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
assert seen["config_path"] == config_file.resolve()
|
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.commands.sync_workspace_templates", lambda _path: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||||
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
|
|
||||||
|
class _FakeCron:
|
||||||
|
def __init__(self, store_path: Path) -> None:
|
||||||
|
seen["cron_store"] = store_path
|
||||||
|
|
||||||
|
class _FakeAgentLoop:
|
||||||
|
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 close_mcp(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
|
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands._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.commands.sync_workspace_templates", lambda _path: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||||
|
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:
|
||||||
|
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 close_mcp(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
|
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands._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.commands.sync_workspace_templates", lambda _path: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||||
|
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:
|
||||||
|
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 close_mcp(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
|
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands._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):
|
def test_agent_overrides_workspace_path(mock_agent_runtime):
|
||||||
workspace_path = Path("/tmp/agent-workspace")
|
workspace_path = Path("/tmp/agent-workspace")
|
||||||
|
|
||||||
@@ -574,7 +740,7 @@ def test_gateway_warns_about_deprecated_memory_window(monkeypatch, tmp_path: Pat
|
|||||||
assert "contextWindowTokens" in result.stdout
|
assert "contextWindowTokens" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
|
def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
|
||||||
config_file = tmp_path / "instance" / "config.json"
|
config_file = tmp_path / "instance" / "config.json"
|
||||||
config_file.parent.mkdir(parents=True)
|
config_file.parent.mkdir(parents=True)
|
||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
@@ -585,7 +751,6 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
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.config.loader.load_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron")
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
@@ -601,7 +766,130 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat
|
|||||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||||
|
|
||||||
assert isinstance(result.exception, _StopGatewayError)
|
assert isinstance(result.exception, _StopGatewayError)
|
||||||
assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json"
|
assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_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.commands.sync_workspace_templates", lambda _path: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||||
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
|
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||||
|
|
||||||
|
class _StopCron:
|
||||||
|
def __init__(self, store_path: Path) -> None:
|
||||||
|
seen["cron_store"] = store_path
|
||||||
|
raise _StopGatewayError("stop")
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron)
|
||||||
|
|
||||||
|
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 = 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.commands.sync_workspace_templates", lambda _path: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||||
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
|
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||||
|
|
||||||
|
class _StopCron:
|
||||||
|
def __init__(self, store_path: Path) -> None:
|
||||||
|
seen["cron_store"] = store_path
|
||||||
|
raise _StopGatewayError("stop")
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron)
|
||||||
|
|
||||||
|
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.commands 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.commands 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:
|
def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from nanobot.config.paths import (
|
|||||||
get_media_dir,
|
get_media_dir,
|
||||||
get_runtime_subdir,
|
get_runtime_subdir,
|
||||||
get_workspace_path,
|
get_workspace_path,
|
||||||
|
is_default_workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,3 +41,9 @@ def test_shared_and_legacy_paths_remain_global() -> None:
|
|||||||
def test_workspace_path_is_explicitly_resolved() -> None:
|
def test_workspace_path_is_explicitly_resolved() -> None:
|
||||||
assert get_workspace_path() == Path.home() / ".nanobot" / "workspace"
|
assert get_workspace_path() == Path.home() / ".nanobot" / "workspace"
|
||||||
assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace"
|
assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_default_workspace_distinguishes_default_and_custom_paths() -> None:
|
||||||
|
assert is_default_workspace(None) is True
|
||||||
|
assert is_default_workspace(Path.home() / ".nanobot" / "workspace") is True
|
||||||
|
assert is_default_workspace("~/custom-workspace") is False
|
||||||
|
|||||||
Reference in New Issue
Block a user