Merge remote-tracking branch 'origin/main'
Some checks failed
Test Suite / test (3.11) (push) Failing after 1m0s
Test Suite / test (3.12) (push) Failing after 19s
Test Suite / test (3.13) (push) Failing after 18s

# Conflicts:
#	nanobot/agent/tools/shell.py
#	tests/agent/test_evaluator.py
#	tests/channels/test_feishu_tool_hint_code_block.py
#	tests/providers/test_litellm_kwargs.py
#	tests/tools/test_web_search_tool.py
This commit is contained in:
Hua
2026-03-24 16:38:50 +08:00
49 changed files with 263 additions and 34 deletions

View File

@@ -0,0 +1,192 @@
import json
from types import SimpleNamespace
import pytest
from typer.testing import CliRunner
from nanobot.cli.commands import _resolve_channel_default_config, app
from nanobot.config.loader import load_config, save_config
runner = CliRunner()
def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"maxTokens": 1234,
"memoryWindow": 42,
}
}
}
),
encoding="utf-8",
)
config = load_config(config_path)
assert config.agents.defaults.max_tokens == 1234
assert config.agents.defaults.context_window_tokens == 65_536
assert not hasattr(config.agents.defaults, "memory_window")
def test_save_config_writes_context_window_tokens_but_not_memory_window(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"maxTokens": 2222,
"memoryWindow": 30,
}
}
}
),
encoding="utf-8",
)
config = load_config(config_path)
save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8"))
defaults = saved["agents"]["defaults"]
assert defaults["maxTokens"] == 2222
assert defaults["contextWindowTokens"] == 65_536
assert "memoryWindow" not in defaults
def test_onboard_does_not_crash_with_legacy_memory_window(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"maxTokens": 3333,
"memoryWindow": 50,
}
}
}
),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda _workspace=None: workspace)
result = runner.invoke(app, ["onboard"], input="n\n")
assert result.exit_code == 0
def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None:
from types import SimpleNamespace
config_path = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_path.write_text(
json.dumps(
{
"channels": {
"qq": {
"enabled": False,
"appId": "",
"secret": "",
"allowFrom": [],
}
}
}
),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda _workspace=None: workspace)
monkeypatch.setattr(
"nanobot.channels.registry.discover_all",
lambda: {
"qq": SimpleNamespace(
default_config=lambda: {
"enabled": False,
"appId": "",
"secret": "",
"allowFrom": [],
"msgFormat": "plain",
}
)
},
)
result = runner.invoke(app, ["onboard"], input="n\n")
assert result.exit_code == 0
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert saved["channels"]["qq"]["msgFormat"] == "plain"
@pytest.mark.parametrize(
("channel_cls", "expected"),
[
(SimpleNamespace(), None),
(SimpleNamespace(default_config="invalid"), None),
(SimpleNamespace(default_config=lambda: None), None),
(SimpleNamespace(default_config=lambda: ["invalid"]), None),
(SimpleNamespace(default_config=lambda: {"enabled": False}), {"enabled": False}),
],
)
def test_resolve_channel_default_config_validates_payload(channel_cls, expected) -> None:
assert _resolve_channel_default_config(channel_cls) == expected
def test_resolve_channel_default_config_skips_exceptions() -> None:
def _raise() -> dict[str, object]:
raise RuntimeError("boom")
assert _resolve_channel_default_config(SimpleNamespace(default_config=_raise)) is None
def test_onboard_refresh_skips_invalid_channel_default_configs(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_path.write_text(json.dumps({"channels": {}}), encoding="utf-8")
def _raise() -> dict[str, object]:
raise RuntimeError("boom")
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda _workspace=None: workspace)
monkeypatch.setattr(
"nanobot.channels.registry.discover_all",
lambda: {
"missing": SimpleNamespace(),
"noncallable": SimpleNamespace(default_config="invalid"),
"none": SimpleNamespace(default_config=lambda: None),
"wrong_type": SimpleNamespace(default_config=lambda: ["invalid"]),
"raises": SimpleNamespace(default_config=_raise),
"qq": SimpleNamespace(
default_config=lambda: {
"enabled": False,
"appId": "",
"secret": "",
"allowFrom": [],
"msgFormat": "plain",
}
),
},
)
result = runner.invoke(app, ["onboard"], input="n\n")
assert result.exit_code == 0
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert "missing" not in saved["channels"]
assert "noncallable" not in saved["channels"]
assert "none" not in saved["channels"]
assert "wrong_type" not in saved["channels"]
assert "raises" not in saved["channels"]
assert saved["channels"]["qq"]["msgFormat"] == "plain"

View File

@@ -0,0 +1,49 @@
from pathlib import Path
from nanobot.config.paths import (
get_bridge_install_dir,
get_cli_history_path,
get_cron_dir,
get_data_dir,
get_legacy_sessions_dir,
get_logs_dir,
get_media_dir,
get_runtime_subdir,
get_workspace_path,
is_default_workspace,
)
def test_runtime_dirs_follow_config_path(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance-a" / "config.json"
monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
assert get_data_dir() == config_file.parent
assert get_runtime_subdir("cron") == config_file.parent / "cron"
assert get_cron_dir() == config_file.parent / "cron"
assert get_logs_dir() == config_file.parent / "logs"
def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "instance-b" / "config.json"
monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
assert get_media_dir() == config_file.parent / "media"
assert get_media_dir("telegram") == config_file.parent / "media" / "telegram"
def test_shared_and_legacy_paths_remain_global() -> None:
assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history"
assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge"
assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions"
def test_workspace_path_is_explicitly_resolved() -> None:
assert get_workspace_path() == Path.home() / ".nanobot" / "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