fix(config): make model preset switching atomic
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
Xubin Ren
co-authored by
Cursor
parent
6f78267c82
commit
c450d6fd3f
@@ -0,0 +1,137 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.command.builtin import (
|
||||
build_help_text,
|
||||
builtin_command_palette,
|
||||
cmd_model,
|
||||
register_builtin_commands,
|
||||
)
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
|
||||
|
||||
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = default_model
|
||||
provider.generation = SimpleNamespace(
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _make_loop(tmp_path) -> AgentLoop:
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_provider("base-model", max_tokens=123),
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
model_presets={
|
||||
"default": ModelPresetConfig(
|
||||
model="base-model",
|
||||
max_tokens=123,
|
||||
context_window_tokens=1000,
|
||||
),
|
||||
"fast": ModelPresetConfig(
|
||||
model="openai/gpt-4.1",
|
||||
max_tokens=4096,
|
||||
context_window_tokens=32_768,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ctx(loop: AgentLoop, raw: str, args: str = "") -> CommandContext:
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=raw)
|
||||
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_lists_current_and_available_presets(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
out = await cmd_model(_ctx(loop, "/model"))
|
||||
|
||||
assert "Current model: `base-model`" in out.content
|
||||
assert "Active preset: `(none)`" in out.content
|
||||
assert "`default`" in out.content
|
||||
assert "`fast`" in out.content
|
||||
assert out.metadata == {"render_as": "text"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_switches_preset(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
out = await cmd_model(_ctx(loop, "/model fast", args="fast"))
|
||||
|
||||
assert "Switched model preset to `fast`." in out.content
|
||||
assert "Model: `openai/gpt-4.1`" in out.content
|
||||
assert loop.model_preset == "fast"
|
||||
assert loop.model == "openai/gpt-4.1"
|
||||
assert loop.subagents.model == "openai/gpt-4.1"
|
||||
assert loop.consolidator.model == "openai/gpt-4.1"
|
||||
assert loop.dream.model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_switches_back_to_default(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.set_model_preset("fast")
|
||||
|
||||
out = await cmd_model(_ctx(loop, "/model default", args="default"))
|
||||
|
||||
assert "Switched model preset to `default`." in out.content
|
||||
assert loop.model_preset == "default"
|
||||
assert loop.model == "base-model"
|
||||
assert loop.context_window_tokens == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_unknown_preset_keeps_old_state(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
out = await cmd_model(_ctx(loop, "/model missing", args="missing"))
|
||||
|
||||
assert "Could not switch model preset" in out.content
|
||||
assert "Available presets: `default`, `fast`" in out.content
|
||||
assert loop.model_preset is None
|
||||
assert loop.model == "base-model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_does_not_depend_on_my_allow_set(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop.tools_config.my.allow_set is False
|
||||
|
||||
await cmd_model(_ctx(loop, "/model fast", args="fast"))
|
||||
|
||||
assert loop.model_preset == "fast"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_registered_as_exact_and_prefix(tmp_path) -> None:
|
||||
router = CommandRouter()
|
||||
register_builtin_commands(router)
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
out = await router.dispatch(_ctx(loop, "/model fast"))
|
||||
|
||||
assert out is not None
|
||||
assert "Switched model preset" in out.content
|
||||
assert loop.model_preset == "fast"
|
||||
|
||||
|
||||
def test_model_command_in_help_and_palette() -> None:
|
||||
palette = builtin_command_palette()
|
||||
|
||||
assert any(item["command"] == "/model" and item["arg_hint"] == "[preset]" for item in palette)
|
||||
assert "/model [preset]" in build_help_text()
|
||||
@@ -22,6 +22,7 @@ class TestIsDispatchableCommand:
|
||||
def test_exact_commands_match(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command("/new")
|
||||
assert router.is_dispatchable_command("/help")
|
||||
assert router.is_dispatchable_command("/model")
|
||||
assert router.is_dispatchable_command("/dream")
|
||||
assert router.is_dispatchable_command("/dream-log")
|
||||
assert router.is_dispatchable_command("/dream-restore")
|
||||
@@ -29,6 +30,7 @@ class TestIsDispatchableCommand:
|
||||
def test_prefix_commands_match(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command("/dream-log abc123")
|
||||
assert router.is_dispatchable_command("/dream-restore def456")
|
||||
assert router.is_dispatchable_command("/model fast")
|
||||
|
||||
def test_priority_commands_not_matched(self, router: CommandRouter) -> None:
|
||||
# Priority commands are NOT in the dispatchable tiers — they are
|
||||
|
||||
Reference in New Issue
Block a user