"""Tests for the /model command and extract_model_and_provider helper.""" from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from tests.conftest import run_async as _run class TestExtractModelAndProvider: """Unit tests for the argument parser helper.""" def test_known_model_no_provider(self): from EvoScientist.commands.implementation.model import ( extract_model_and_provider, ) name, prov = extract_model_and_provider(["claude-sonnet-4-6"]) assert name == "claude-sonnet-4-6" assert prov == "anthropic" def test_known_model_with_provider_override(self): from EvoScientist.commands.implementation.model import ( extract_model_and_provider, ) name, prov = extract_model_and_provider(["claude-sonnet-4-6", "openrouter"]) assert name == "claude-sonnet-4-6" assert prov == "openrouter" def test_unknown_model_no_provider_raises(self): from EvoScientist.commands.implementation.model import ( extract_model_and_provider, ) with pytest.raises(ValueError, match="Unknown model"): extract_model_and_provider(["nonexistent-model-xyz"]) def test_unknown_model_with_provider_still_raises(self): from EvoScientist.commands.implementation.model import ( extract_model_and_provider, ) # Unknown models are always rejected, even with an explicit provider with pytest.raises(ValueError, match="Unknown model"): extract_model_and_provider(["my-custom-model", "custom-openai"]) def test_provider_override_on_known_model(self): from EvoScientist.commands.implementation.model import ( extract_model_and_provider, ) # Known model with explicit provider override uses the override name, prov = extract_model_and_provider(["claude-sonnet-4-6", "openrouter"]) assert name == "claude-sonnet-4-6" assert prov == "openrouter" def test_ollama_provider_accepts_arbitrary_name(self): """Ollama models are locally-installed — the registry doesn't know them. The ``ollama`` provider must pass any name through verbatim.""" from EvoScientist.commands.implementation.model import ( extract_model_and_provider, ) name, prov = extract_model_and_provider(["llama3.3:8b", "ollama"]) assert name == "llama3.3:8b" assert prov == "ollama" def test_ollama_provider_accepts_dotted_tag(self): from EvoScientist.commands.implementation.model import ( extract_model_and_provider, ) name, prov = extract_model_and_provider(["qwen3-coder-next:latest", "ollama"]) assert name == "qwen3-coder-next:latest" assert prov == "ollama" class TestModelCommandUnknownModel: """Verify error message for unknown models.""" def test_unknown_model_shows_error(self): from EvoScientist.commands.implementation.model import ModelCommand cmd = ModelCommand() ui = MagicMock() ui.supports_interactive = True cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") ctx = MagicMock() ctx.ui = ui with patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ): _run(cmd.execute(ctx, ["nonexistent-model-xyz"])) ui.append_system.assert_called_once() call_args = ui.append_system.call_args assert "Unknown model" in call_args[0][0] assert call_args[1]["style"] == "red" class TestModelCommandPickerCancelled: """Verify no-op when the interactive picker is cancelled.""" def test_picker_returns_none(self): from EvoScientist.commands.implementation.model import ModelCommand cmd = ModelCommand() ui = MagicMock() ui.supports_interactive = True ui.wait_for_model_pick = AsyncMock(return_value=None) cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") ctx = MagicMock() ctx.ui = ui with patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ): _run(cmd.execute(ctx, [])) # No model switch should have happened ui.append_system.assert_not_called() class TestModelCommandSwitch: """Verify a successful model switch updates config and rebuilds agent.""" def test_switch_known_model(self): from EvoScientist.commands.implementation.model import ModelCommand cmd = ModelCommand() ui = MagicMock() ui.supports_interactive = True cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") new_agent = MagicMock() ctx = MagicMock() ctx.ui = ui ctx.workspace_dir = "/tmp/test" ctx.checkpointer = MagicMock() with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch("EvoScientist.EvoScientist._build_chat_model"), patch("EvoScientist.EvoScientist.set_active_config") as set_cfg, patch("EvoScientist.EvoScientist.set_chat_model_instance"), patch( "EvoScientist.cli.agent._load_agent", return_value=new_agent, ), ): _run(cmd.execute(ctx, ["claude-opus-4-8"])) # The switch is committed via set_active_config(temp_cfg), not by # mutating the original cfg object in place. set_cfg.assert_called_once() committed = set_cfg.call_args[0][0] assert committed.model == "claude-opus-4-8" assert committed.provider == "anthropic" # Agent should be replaced on context assert ctx.agent == new_agent # Success message shown ui.append_system.assert_called_once() msg = ui.append_system.call_args[0][0] assert "claude-opus-4-8" in msg assert "anthropic" in msg def test_switch_with_save_flag(self): from EvoScientist.commands.implementation.model import ModelCommand cmd = ModelCommand() ui = MagicMock() ui.supports_interactive = True cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") ctx = MagicMock() ctx.ui = ui ctx.workspace_dir = "/tmp/test" ctx.checkpointer = MagicMock() with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch("EvoScientist.EvoScientist._build_chat_model"), patch("EvoScientist.EvoScientist.set_active_config"), patch("EvoScientist.EvoScientist.set_chat_model_instance"), patch( "EvoScientist.cli.agent._load_agent", return_value=MagicMock(), ), patch("EvoScientist.config.settings.set_config_value") as mock_save, ): _run(cmd.execute(ctx, ["claude-opus-4-8", "--save"])) # Config file should be updated mock_save.assert_any_call("model", "claude-opus-4-8") mock_save.assert_any_call("provider", "anthropic") # Success message should mention save msg = ui.append_system.call_args[0][0] assert "saved to config" in msg def test_switch_without_save_flag_does_not_persist(self): from EvoScientist.commands.implementation.model import ModelCommand cmd = ModelCommand() ui = MagicMock() ui.supports_interactive = True cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") ctx = MagicMock() ctx.ui = ui ctx.workspace_dir = "/tmp/test" ctx.checkpointer = MagicMock() with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch("EvoScientist.EvoScientist._build_chat_model"), patch("EvoScientist.EvoScientist.set_active_config"), patch("EvoScientist.EvoScientist.set_chat_model_instance"), patch( "EvoScientist.cli.agent._load_agent", return_value=MagicMock(), ), patch("EvoScientist.config.settings.set_config_value") as mock_save, ): _run(cmd.execute(ctx, ["claude-opus-4-8"])) # Config file should NOT be updated mock_save.assert_not_called() # Message should not mention save msg = ui.append_system.call_args[0][0] assert "saved to config" not in msg class TestModelCommandFailure: """Verify error handling when chat-model construction raises.""" def test_build_chat_model_error(self): from EvoScientist.commands.implementation.model import ModelCommand cmd = ModelCommand() ui = MagicMock() ui.supports_interactive = True cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") ctx = MagicMock() ctx.ui = ui ctx.workspace_dir = "/tmp/test" ctx.checkpointer = MagicMock() with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch( "EvoScientist.EvoScientist._build_chat_model", side_effect=RuntimeError("API key missing"), ) as mock_build, ): _run(cmd.execute(ctx, ["claude-opus-4-8"])) mock_build.assert_called_once() ui.append_system.assert_called_once() call_args = ui.append_system.call_args assert "Failed to switch model" in call_args[0][0] assert call_args[1]["style"] == "red" @pytest.fixture def evo_module_state(): """Snapshot and restore ``EvoScientist.EvoScientist`` module globals. The chat-model cache tests mutate ``_chat_model`` / ``_chat_model_key`` / ``_config`` / ``_EvoScientist_agent`` directly. This fixture guarantees all four are restored — even if a test body grows an early return — so later tests in the suite see a clean module state. """ import EvoScientist.EvoScientist as mod snapshot = ( mod._chat_model, mod._chat_model_key, mod._config, mod._EvoScientist_agent, ) try: yield mod finally: ( mod._chat_model, mod._chat_model_key, mod._config, mod._EvoScientist_agent, ) = snapshot class TestEnsureChatModelCacheInvalidation: """Regression tests for issue #179: /model switch lagged by one step. Root cause: ``_ensure_chat_model()`` returned a cached ``_chat_model`` without checking whether ``cfg.model`` / ``cfg.provider`` had changed since the cache was populated. ``ModelCommand._apply_model`` builds a new agent *before* ``set_chat_model`` runs, so the new agent was bound to the *previous* cached model. Fix: track a ``(model, provider)`` key alongside ``_chat_model`` and rebuild on mismatch. """ def test_cache_rebuilds_when_config_model_changes(self, evo_module_state): """After cfg.model changes, _ensure_chat_model must return a new instance.""" mod = evo_module_state cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") m1 = MagicMock(name="model-1") m2 = MagicMock(name="model-2") mod._chat_model = None mod._chat_model_key = None mod._config = cfg mod._EvoScientist_agent = None with patch("EvoScientist.llm.get_chat_model", side_effect=[m1, m2]) as gm: first = mod._ensure_chat_model() assert first is m1 # Same config → cache hit, no rebuild. again = mod._ensure_chat_model() assert again is m1 assert gm.call_count == 1 # Simulate /model switch writing the new choice into cfg. cfg.model = "minimax-m2.7" cfg.provider = "openrouter" second = mod._ensure_chat_model() # Must be the NEW model instance, not the cached one. assert second is m2 assert second is not first assert gm.call_count == 2 # Second call used the new model name + provider. _, kwargs = gm.call_args assert kwargs == {"model": "minimax-m2.7", "provider": "openrouter"} def test_cache_rebuilds_when_only_provider_changes(self, evo_module_state): """Same model name, different provider (openrouter vs anthropic) must rebuild.""" mod = evo_module_state cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") m1 = MagicMock(name="anthropic-model") m2 = MagicMock(name="openrouter-model") mod._chat_model = None mod._chat_model_key = None mod._config = cfg mod._EvoScientist_agent = None with patch("EvoScientist.llm.get_chat_model", side_effect=[m1, m2]) as gm: assert mod._ensure_chat_model() is m1 cfg.provider = "openrouter" assert mod._ensure_chat_model() is m2 assert gm.call_count == 2 def test_set_chat_model_updates_key(self, evo_module_state): """set_chat_model must keep _chat_model_key in sync to avoid an extra rebuild on the very next _ensure_chat_model() call.""" mod = evo_module_state cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") m_set = MagicMock(name="explicit-set-model") mod._chat_model = None mod._chat_model_key = None mod._config = cfg mod._EvoScientist_agent = None with patch("EvoScientist.llm.get_chat_model", return_value=m_set) as gm: mod.set_chat_model("minimax-m2.7", provider="openrouter") assert mod._chat_model is m_set assert mod._chat_model_key == ("minimax-m2.7", "openrouter") # Align cfg to what set_chat_model was called with. cfg.model = "minimax-m2.7" cfg.provider = "openrouter" # Now _ensure_chat_model should NOT rebuild (key matches cfg). assert mod._ensure_chat_model() is m_set assert gm.call_count == 1 def test_set_chat_model_is_no_op_when_key_already_matches(self, evo_module_state): """set_chat_model must NOT rebuild when the cache already holds the requested (model, provider). Under the /model flow, ``_load_agent`` already rebuilt ``_chat_model`` via ``_ensure_chat_model`` before ``set_chat_model`` is reached, so the subsequent set should be idempotent — reusing the same Python instance (and thus the same underlying HTTP client) that ``ctx.agent`` is already bound to. """ mod = evo_module_state existing = MagicMock(name="existing-model") mod._chat_model = existing mod._chat_model_key = ("minimax-m2.7", "openrouter") mod._config = SimpleNamespace(model="minimax-m2.7", provider="openrouter") mod._EvoScientist_agent = None with patch("EvoScientist.llm.get_chat_model") as gm: returned = mod.set_chat_model("minimax-m2.7", provider="openrouter") # Fast path: returned the EXISTING instance, no rebuild. assert returned is existing assert mod._chat_model is existing gm.assert_not_called() class TestApplyModelIntegration: """End-to-end regression for #179 + #183: `_apply_model` must produce an agent bound to the NEW model, threaded in via the pure ``create_cli_agent`` path. Exercises the real chain: ``_apply_model → _build_chat_model → _load_agent(chat_model=...) → set_active_config / set_chat_model_instance`` Only ``_load_agent`` is faked (to avoid spinning up deepagents, MCP tools, middleware, and subagent YAML); it binds the ``chat_model`` it receives. ``get_chat_model`` returns a distinct sentinel per ``(model, provider)`` pair so we can assert on identity. """ def test_new_agent_is_bound_to_newly_selected_model(self, evo_module_state): from EvoScientist.commands.implementation.model import ModelCommand from EvoScientist.config.settings import EvoScientistConfig mod = evo_module_state sentinels: dict[tuple[str, str | None], MagicMock] = {} def _fake_get_chat_model(model, provider=None): key = (model, provider) if key not in sentinels: sentinels[key] = MagicMock(name=f"chat_model[{model}|{provider}]") sentinels[key]._bound_model = model sentinels[key]._bound_provider = provider return sentinels[key] def _fake_load_agent( workspace_dir=None, checkpointer=None, config=None, chat_model=None, *, on_mcp_progress=None, ): # The pure path threads the freshly built chat model in; bind it # directly instead of re-deriving via _ensure_chat_model. agent = MagicMock(name="fake-agent") agent._bound_model = chat_model return agent cfg = EvoScientistConfig(model="claude-sonnet-4-6", provider="anthropic") ctx = MagicMock() ctx.ui = MagicMock() ctx.ui.supports_interactive = True ctx.workspace_dir = "/tmp/test_integration" ctx.checkpointer = None # Prime: _chat_model already holds the OLD (default) model — # this is the state that caused the off-by-one in production. mod._config = cfg mod._chat_model = _fake_get_chat_model("claude-sonnet-4-6", "anthropic") mod._chat_model_key = ("claude-sonnet-4-6", "anthropic") mod._EvoScientist_agent = None old_model = mod._chat_model with ( patch( "EvoScientist.llm.get_chat_model", side_effect=_fake_get_chat_model, ), patch( "EvoScientist.cli.agent._load_agent", side_effect=_fake_load_agent, ), ): cmd = ModelCommand() _run(cmd._apply_model(ctx, "minimax-m2.7", "openrouter")) # The agent produced by _apply_model must be bound to the # NEWLY requested model, threaded in via chat_model=. assert ctx.agent._bound_model is not old_model assert ctx.agent._bound_model._bound_model == "minimax-m2.7" assert ctx.agent._bound_model._bound_provider == "openrouter" # Global state reflects the switch end-to-end, committed via the setters. assert mod._chat_model_key == ("minimax-m2.7", "openrouter") assert mod._chat_model is sentinels[("minimax-m2.7", "openrouter")] # The switch is applied to the LIVE cfg in place and ``_config`` stays # bound to that same object, so callers holding the active config by # reference (e.g. serve's ``agent_holder["config"]``) observe the swap. assert mod._config is cfg assert cfg.model == "minimax-m2.7" assert cfg.provider == "openrouter" # User-visible success message. msg = ctx.ui.append_system.call_args[0][0] assert "minimax-m2.7" in msg assert "openrouter" in msg class TestApplyModelPreservesConfigByReference: """Regression for the din0s review on #267: serve mode (and any long-lived caller) holds the active config object by reference via ``agent_holder["config"]``. The pure-path commit must apply the switch to that LIVE object in place — not rebind ``_config`` to a fresh ``temp_cfg`` copy — otherwise a later workspace-changing ``/resume`` reloads the agent from the stale startup config and silently reverts the ``/model`` switch. Critically this must hold across *repeated* switches: the earlier "also mutate cfg but still rebind to temp_cfg" remedy only survives one switch (the held object stops being the active ``_config`` after the first). """ def test_held_config_reference_tracks_repeated_switches(self, evo_module_state): from EvoScientist.commands.implementation.model import ModelCommand from EvoScientist.config.settings import EvoScientistConfig mod = evo_module_state def _fake_get_chat_model(model, provider=None): m = MagicMock(name=f"chat_model[{model}|{provider}]") m._bound_model = model return m def _fake_load_agent( workspace_dir=None, checkpointer=None, config=None, chat_model=None, *, on_mcp_progress=None, ): return MagicMock(name="fake-agent") cfg = EvoScientistConfig(model="claude-sonnet-4-6", provider="anthropic") mod._config = cfg mod._chat_model = None mod._chat_model_key = None mod._EvoScientist_agent = None # Simulate serve capturing the startup config object once (commands.py: # ``agent_holder = {... "config": config}``) and never re-reading it. agent_holder = {"config": cfg} ctx = MagicMock() ctx.ui = MagicMock() ctx.ui.supports_interactive = True ctx.workspace_dir = "/tmp/test_byref" ctx.checkpointer = None cmd = ModelCommand() with ( patch( "EvoScientist.llm.get_chat_model", side_effect=_fake_get_chat_model, ), patch( "EvoScientist.cli.agent._load_agent", side_effect=_fake_load_agent, ), ): for model, provider in [ ("claude-opus-4-8", "anthropic"), ("minimax-m2.7", "openrouter"), ("claude-sonnet-4-6", "anthropic"), ]: _run(cmd._apply_model(ctx, model, provider)) # The held reference must reflect the LATEST switch on every # iteration — not just the first — and stay the active config. assert agent_holder["config"].model == model assert agent_holder["config"].provider == provider assert mod._config is agent_holder["config"] class TestModelCommandLoadAgentFailure: """Verify the transactional ordering: when ``_load_agent`` raises, nothing downstream (``set_chat_model``, ``cfg`` mutation, ``set_config_value``) should happen. This is the core guarantee of the refactor that established "build agent first, commit state only on success". Without this test the ordering could silently regress (e.g. if ``_apply_model`` were reordered to call ``set_chat_model`` first).""" def test_load_agent_error_is_transactional(self): from EvoScientist.commands.implementation.model import ModelCommand cmd = ModelCommand() ui = MagicMock() ui.supports_interactive = True cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") ctx = MagicMock() ctx.ui = ui ctx.workspace_dir = "/tmp/test" ctx.checkpointer = MagicMock() with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch("EvoScientist.EvoScientist._build_chat_model"), patch( "EvoScientist.cli.agent._load_agent", side_effect=RuntimeError("agent build failed"), ) as mock_load, patch( "EvoScientist.EvoScientist.set_active_config", ) as mock_set_cfg, patch( "EvoScientist.EvoScientist.set_chat_model_instance", ) as mock_set_model, patch( "EvoScientist.config.settings.set_config_value", ) as mock_save, ): # Pass ``--save`` to strengthen the assertion: if the ordering # ever regresses, ``set_config_value`` would be called with # stale data. _run(cmd.execute(ctx, ["claude-opus-4-8", "--save"])) # _load_agent was attempted (transactional first step). mock_load.assert_called_once() # Commit setters and downstream side-effects must NOT have happened. mock_set_cfg.assert_not_called() mock_set_model.assert_not_called() mock_save.assert_not_called() # Config must be untouched. assert cfg.model == "claude-sonnet-4-6" assert cfg.provider == "anthropic" # User sees a red error message. ui.append_system.assert_called_once() call_args = ui.append_system.call_args assert "Failed to switch model" in call_args[0][0] assert call_args[1]["style"] == "red" class TestApplyModelLoadAgentFailureTransactional: """Regression for #183: when agent construction fails, the session stays on the original model with no snapshot/restore. Because ``create_cli_agent(config=..., chat_model=...)`` is now pure — it writes none of the four config/model globals — and ``_apply_model`` commits only after a successful build, a failing ``_load_agent`` leaves all four globals untouched. This replaces the old snapshot/restore rollback test. Complements :class:`TestModelCommandLoadAgentFailure`, which asserts the downstream setters never run on failure. """ def test_globals_unchanged_when_load_agent_raises(self, evo_module_state): from EvoScientist.commands.implementation.model import ModelCommand from EvoScientist.config.settings import EvoScientistConfig mod = evo_module_state def _fake_load_agent( workspace_dir=None, checkpointer=None, config=None, chat_model=None, *, on_mcp_progress=None, ): # The pure path writes no globals; mimic a failure partway through # agent wiring (middleware build, deepagents, MCP reconnect, ...). raise RuntimeError("middleware build failed") cfg = EvoScientistConfig(model="claude-sonnet-4-6", provider="anthropic") old_model = MagicMock(name="old-model") old_agent = MagicMock(name="old-default-agent") mod._config = cfg mod._chat_model = old_model mod._chat_model_key = ("claude-sonnet-4-6", "anthropic") mod._EvoScientist_agent = old_agent ctx = MagicMock() ctx.ui = MagicMock() ctx.ui.supports_interactive = True ctx.workspace_dir = "/tmp/test_rollback" ctx.checkpointer = None with ( patch( "EvoScientist.EvoScientist._build_chat_model", return_value=MagicMock(name="new-model"), ), patch( "EvoScientist.cli.agent._load_agent", side_effect=_fake_load_agent, ), ): cmd = ModelCommand() _run(cmd._apply_model(ctx, "minimax-m2.7", "openrouter")) # All four globals are unchanged — nothing was committed. assert mod._config is cfg assert mod._chat_model is old_model assert mod._chat_model_key == ("claude-sonnet-4-6", "anthropic") assert mod._EvoScientist_agent is old_agent # The ``cfg`` object itself was not mutated. assert cfg.model == "claude-sonnet-4-6" assert cfg.provider == "anthropic" # User sees an error message. msg = ctx.ui.append_system.call_args[0][0] assert "Failed to switch model" in msg class TestModelCommandOllamaPicker: """Verify Ollama discovery augments the picker entries and the sentinel is always present when Ollama is configured.""" def _make_ctx_and_cfg(self, *, ollama_base_url: str | None): cfg = SimpleNamespace( model="claude-sonnet-4-6", provider="anthropic", ollama_base_url=ollama_base_url, ) ui = MagicMock() ui.supports_interactive = True ui.wait_for_model_pick = AsyncMock(return_value=None) ctx = MagicMock() ctx.ui = ui return ctx, cfg, ui def test_picker_entries_include_detected_ollama_models(self): """When Ollama is reachable, detected models appear in entries with provider='ollama' and the Custom sentinel is appended.""" from EvoScientist.commands.implementation.model import ModelCommand ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="http://localhost:11434") async def fake_discover(base_url, *, timeout): return ["llama3.3:latest", "qwen3:8b"] with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch( "EvoScientist.llm.ollama_discovery.discover_ollama_models", side_effect=fake_discover, ), ): _run(ModelCommand().execute(ctx, [])) entries = ui.wait_for_model_pick.call_args[0][0] ollama_rows = [(n, mid, p) for (n, mid, p) in entries if p == "ollama"] assert ("llama3.3:latest", "llama3.3:latest", "ollama") in ollama_rows assert ("qwen3:8b", "qwen3:8b", "ollama") in ollama_rows assert ( "Custom Ollama model...", "__custom_ollama__", "ollama", ) in ollama_rows def test_picker_entries_include_sentinel_when_discovery_empty(self): """Daemon unreachable / no models pulled — sentinel is the user's escape hatch and must always be present.""" from EvoScientist.commands.implementation.model import ModelCommand ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="http://localhost:11434") async def fake_discover(base_url, *, timeout): return [] with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch( "EvoScientist.llm.ollama_discovery.discover_ollama_models", side_effect=fake_discover, ), ): _run(ModelCommand().execute(ctx, [])) entries = ui.wait_for_model_pick.call_args[0][0] ollama_rows = [(n, mid, p) for (n, mid, p) in entries if p == "ollama"] assert ollama_rows == [ ("Custom Ollama model...", "__custom_ollama__", "ollama") ] def test_picker_skips_ollama_section_when_not_configured(self): """ollama_base_url unset → no discovery call, no ollama entries, no sentinel (issue non-goal: no implicit localhost detection).""" from EvoScientist.commands.implementation.model import ModelCommand ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="") discovery = AsyncMock(return_value=["should-never-appear"]) with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch( "EvoScientist.llm.ollama_discovery.discover_ollama_models", discovery, ), ): _run(ModelCommand().execute(ctx, [])) discovery.assert_not_called() entries = ui.wait_for_model_pick.call_args[0][0] assert not any(p == "ollama" for (_, _, p) in entries) def test_picker_handles_cfg_without_ollama_base_url_attr(self): """getattr(cfg, 'ollama_base_url', None) fallback: old configs (or SimpleNamespace test fixtures) may not carry the attribute at all. Must not raise AttributeError, must not probe.""" from EvoScientist.commands.implementation.model import ModelCommand # Deliberately omit ollama_base_url from the namespace. cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") ui = MagicMock() ui.supports_interactive = True ui.wait_for_model_pick = AsyncMock(return_value=None) ctx = MagicMock() ctx.ui = ui discovery = AsyncMock(return_value=["should-never-appear"]) with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch( "EvoScientist.llm.ollama_discovery.discover_ollama_models", discovery, ), ): _run(ModelCommand().execute(ctx, [])) discovery.assert_not_called() entries = ui.wait_for_model_pick.call_args[0][0] assert not any(p == "ollama" for (_, _, p) in entries) def test_picker_sentinel_result_is_treated_as_cancel(self): """Defense-in-depth: if the widget ever returns the sentinel name itself (shouldn't happen — it should substitute the typed name), dispatch treats it as a cancel and does NOT call _apply_model.""" from EvoScientist.commands.implementation.model import ModelCommand ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="http://localhost:11434") ui.wait_for_model_pick = AsyncMock(return_value=("__custom_ollama__", "ollama")) async def fake_discover(base_url, *, timeout): return [] with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch( "EvoScientist.llm.ollama_discovery.discover_ollama_models", side_effect=fake_discover, ), patch("EvoScientist.cli.agent._load_agent") as load_agent, ): _run(ModelCommand().execute(ctx, [])) load_agent.assert_not_called() assert cfg.model == "claude-sonnet-4-6" # unchanged def test_picker_applies_detected_ollama_model(self): """User picks a live-detected Ollama model → _apply_model is invoked with (name, "ollama") and the agent is rebuilt.""" from EvoScientist.commands.implementation.model import ModelCommand ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="http://localhost:11434") ctx.workspace_dir = "/tmp/test" ctx.checkpointer = MagicMock() ui.wait_for_model_pick = AsyncMock(return_value=("llama3.3", "ollama")) async def fake_discover(base_url, *, timeout): return ["llama3.3"] with ( patch( "EvoScientist.EvoScientist._ensure_config", return_value=cfg, ), patch( "EvoScientist.llm.ollama_discovery.discover_ollama_models", side_effect=fake_discover, ), patch("EvoScientist.EvoScientist._build_chat_model"), patch("EvoScientist.EvoScientist.set_active_config") as set_cfg, patch("EvoScientist.EvoScientist.set_chat_model_instance"), patch( "EvoScientist.cli.agent._load_agent", return_value=MagicMock(), ), ): _run(ModelCommand().execute(ctx, [])) # Committed via set_active_config(temp_cfg); original cfg untouched. set_cfg.assert_called_once() committed = set_cfg.call_args[0][0] assert committed.model == "llama3.3" assert committed.provider == "ollama"