diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 232c65cf..7b31df3e 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -73,6 +73,10 @@ DEFAULT_TOOL = "codex" BUNDLE_VERSION = 1 _MANAGED_SETTINGS_TOOLS = {"claude", "codex"} +_NATIVE_UPGRADE_COMMANDS = { + "claude": ["claude", "upgrade"], + "codex": ["codex", "update"], +} # ucode tool -> `databricks aitools` agent id. gemini/pi aren't supported. AITOOLS_AGENT_TOKENS = { @@ -111,14 +115,18 @@ def _update_installed_tool_binary(tool: str, version: str | None = None) -> bool package = spec["package"] target = f"{package}@{version}" if version else package - if not shutil.which("npm"): - print_warning(f"`npm` is not available to update {spec['display']}; continuing.") - return False + if tool in _NATIVE_UPGRADE_COMMANDS and version is None and shutil.which(binary): + command = _NATIVE_UPGRADE_COMMANDS[tool] + else: + if not shutil.which("npm"): + print_warning(f"`npm` is not available to update {spec['display']}; continuing.") + return False + command = ["npm", "install", "-g", target] - print_note(f"Updating {spec['display']}...") + print_note(f"Upgrading {spec['display']}...") try: - subprocess.run(["npm", "install", "-g", target], check=True, timeout=300) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + subprocess.run(command, check=True, timeout=300) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): print_warning(f"Could not update {spec['display']}; continuing.") return False @@ -134,13 +142,6 @@ def _minimum_version_error(tool: str) -> str | None: return checker() -def _required_update_message(tool: str) -> str | None: - checker = getattr(_MODULES[tool], "required_update_message", None) - if not callable(checker): - return None - return checker() - - def _too_new_downgrade(tool: str) -> tuple[str, str] | None: """Return (installed_version, downgrade_target) when the installed tool is too new to work, or None. Agents opt in by defining `too_new_downgrade`.""" @@ -189,16 +190,20 @@ def install_tool_binary( # against the gateway), so check it on every launch — not just when # auto-configuring — mirroring the minimum-version gate below. too_new = _maybe_downgrade_too_new_tool(tool, prompt=prompt_optional_updates) - - if update_existing and not too_new: - required_update = _required_update_message(tool) - if required_update: - # Required updates are forced regardless of prompt preference; - # the tool won't function on an unsupported version. - print_warning(required_update) - if not _update_installed_tool_binary(tool): - raise RuntimeError(_minimum_version_error(tool) or required_update) version_error = _minimum_version_error(tool) + + should_update = update_existing or tool in _NATIVE_UPGRADE_COMMANDS + if should_update and not too_new and version_error: + print_warning(version_error) + if ( + tool in _NATIVE_UPGRADE_COMMANDS + and prompt_optional_updates + and not prompt_yes_no(f"Upgrade {spec['display']} if available?") + ): + raise RuntimeError(version_error) + if not _update_installed_tool_binary(tool): + raise RuntimeError(version_error) + version_error = _minimum_version_error(tool) if version_error: raise RuntimeError(version_error) return True @@ -251,6 +256,8 @@ def tool_binary_installed(tool: str) -> bool: def tool_update_available(tool: str) -> tuple[str, str] | None: """Return ``(current, latest)`` when a newer agent CLI is published, else None. Read-only wrapper over the npm update check — for ``ucode doctor``.""" + if tool in _NATIVE_UPGRADE_COMMANDS: + return None checker = getattr(_MODULES[tool], "is_update_available", None) if callable(checker): return checker() @@ -263,6 +270,16 @@ def update_tool_binary(tool: str) -> bool: return _update_installed_tool_binary(tool) +def tool_uses_native_updater(tool: str) -> bool: + """Whether upgrades are resolved and installed entirely by the agent CLI.""" + return tool in _NATIVE_UPGRADE_COMMANDS + + +def tool_version_error(tool: str) -> str | None: + """Return an active minimum-version blocker for a configured agent.""" + return _minimum_version_error(tool) + + def tracing_mlflow_ok() -> bool: """True when the `mlflow` CLI that Claude tracing needs is installed and in the supported version range. Read-only — for ``ucode doctor``.""" diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 01b3a1f9..9b99a890 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -107,16 +107,6 @@ def _parse_version(value: str) -> tuple[int, int, int] | None: return int(major), int(minor), int(patch) -def _installed_version_status() -> tuple[str, bool] | None: - if os.environ.get(GATEWAY_MODEL_DISCOVERY_ENV_VAR) != "1" and not smart_routing_v2.enabled(): - return None - version = agent_version(SPEC["binary"]) - parsed = _parse_version(version) - if parsed is None: - return None - return version, parsed < MINIMUM_CLAUDE_VERSION - - def _minimum_version_requirement_message(version: str) -> str: feature = "Smart routing" if smart_routing_v2.enabled() else "Model discovery" return ( @@ -126,21 +116,11 @@ def _minimum_version_requirement_message(version: str) -> str: def minimum_version_error() -> str | None: - status = _installed_version_status() - if status is None: - return None - version, is_too_old = status - if not is_too_old: - return None - return _minimum_version_requirement_message(version) - - -def required_update_message() -> str | None: - status = _installed_version_status() - if status is None: + if os.environ.get(GATEWAY_MODEL_DISCOVERY_ENV_VAR) != "1" and not smart_routing_v2.enabled(): return None - version, is_too_old = status - if not is_too_old: + version = agent_version(SPEC["binary"]) + parsed = _parse_version(version) + if parsed is None or parsed >= MINIMUM_CLAUDE_VERSION: return None return _minimum_version_requirement_message(version) diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index 625329f3..7ba8be73 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -98,12 +98,18 @@ def _parse_version(value: str) -> tuple[int, int, int] | None: return int(major), int(minor), int(patch) -def _installed_version_status() -> tuple[str, bool] | None: +def minimum_version_error() -> str | None: + """Return the active smart-routing version blocker, if any.""" + if not smart_routing_v2.enabled(): + return None version = agent_version(SPEC["binary"]) parsed = _parse_version(version) - if parsed is None: + if parsed is None or parsed >= MINIMUM_ROUTING_CODEX_VERSION: return None - return version, parsed < MINIMUM_CODEX_VERSION + return ( + "Codex smart routing requires Codex " + f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer; found {version}." + ) def _use_legacy_layout() -> bool: @@ -499,13 +505,6 @@ def launch(state: dict, tool_args: list[str]) -> None: binary = SPEC["binary"] workspace = state.get("workspace") if smart_routing_v2.enabled(): - version_text = agent_version(binary) - parsed_version = _parse_version(version_text) - if parsed_version is not None and parsed_version < MINIMUM_ROUTING_CODEX_VERSION: - raise RuntimeError( - "Codex smart routing requires Codex " - f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer; found {version_text}." - ) def _app_server_start_model() -> str: managed_model = default_model(state) diff --git a/src/ucode/agents/opencode.py b/src/ucode/agents/opencode.py index b07aef56..0d79da5f 100644 --- a/src/ucode/agents/opencode.py +++ b/src/ucode/agents/opencode.py @@ -145,10 +145,6 @@ def _minimum_version_message() -> str | None: ) -def required_update_message() -> str | None: - return _minimum_version_message() - - def minimum_version_error() -> str | None: message = _minimum_version_message() if message is None: diff --git a/src/ucode/doctor.py b/src/ucode/doctor.py index 31d8bc39..29a74cdc 100644 --- a/src/ucode/doctor.py +++ b/src/ucode/doctor.py @@ -20,6 +20,8 @@ ensure_tracing_mlflow_cli, tool_binary_installed, tool_update_available, + tool_uses_native_updater, + tool_version_error, tracing_mlflow_ok, update_tool_binary, ) @@ -168,6 +170,23 @@ def _check_agent_clis() -> list[Check]: ) ) continue + if tool_uses_native_updater(tool): + blocker = tool_version_error(tool) + if blocker: + checks.append( + Check( + display, + "warn", + blocker, + Suggestion( + f"Upgrade {display} if available?", + lambda t=tool: update_tool_binary(t), + ), + ) + ) + else: + checks.append(Check(display, "ok", "installed; upgrades managed by agent CLI")) + continue with spinner(f"Checking {display} for updates..."): update = tool_update_available(tool) if update: diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index 61f07438..788c64a8 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -39,7 +39,6 @@ def test_supported_version(self, monkeypatch, version): monkeypatch.setattr(claude, "agent_version", lambda _binary: version) assert claude.minimum_version_error() is None - assert claude.required_update_message() is None def test_older_version_requires_update(self, monkeypatch): monkeypatch.setenv(v2.ENV_VAR, "1") @@ -49,10 +48,6 @@ def test_older_version_requires_update(self, monkeypatch): "Smart routing requires Claude Code 2.1.248 or newer. " "Your current version is Claude Code 2.1.247." ) - assert claude.required_update_message() == ( - "Smart routing requires Claude Code 2.1.248 or newer. " - "Your current version is Claude Code 2.1.247." - ) def test_older_version_requires_update_for_model_discovery(self, monkeypatch): monkeypatch.setenv(claude.GATEWAY_MODEL_DISCOVERY_ENV_VAR, "1") @@ -63,7 +58,6 @@ def test_older_version_requires_update_for_model_discovery(self, monkeypatch): "Your current version is Claude Code 2.1.247." ) assert claude.minimum_version_error() == expected - assert claude.required_update_message() == expected def test_smart_routing_message_wins_when_both_features_are_enabled(self, monkeypatch): monkeypatch.setenv(v2.ENV_VAR, "1") @@ -77,7 +71,6 @@ def test_unknown_version_does_not_block(self, monkeypatch): monkeypatch.setattr(claude, "agent_version", lambda _binary: "unknown") assert claude.minimum_version_error() is None - assert claude.required_update_message() is None def test_older_version_is_not_validated_without_discovery_features(self, monkeypatch): monkeypatch.delenv(v2.ENV_VAR, raising=False) @@ -85,7 +78,6 @@ def test_older_version_is_not_validated_without_discovery_features(self, monkeyp monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.1.247") assert claude.minimum_version_error() is None - assert claude.required_update_message() is None class TestRenderOverlay: diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index 2cd11dc3..d30e4b2c 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -25,6 +25,21 @@ def test_display(self): assert codex.SPEC["display"] == "Codex" +class TestMinimumVersion: + def test_smart_routing_old_version_requires_update(self, monkeypatch): + monkeypatch.setenv(codex.smart_routing_v2.ENV_VAR, "1") + monkeypatch.setattr(codex, "agent_version", lambda _binary: "0.144.0") + + expected = "Codex smart routing requires Codex 0.145.0 or newer; found 0.144.0." + assert codex.minimum_version_error() == expected + + def test_old_version_is_not_blocked_without_smart_routing(self, monkeypatch): + monkeypatch.delenv(codex.smart_routing_v2.ENV_VAR, raising=False) + monkeypatch.setattr(codex, "agent_version", lambda _binary: "0.144.0") + + assert codex.minimum_version_error() is None + + class TestHasUcodeConfig: def test_detects_profile_config(self, tmp_path, monkeypatch): config_path = tmp_path / "ucode.config.toml" diff --git a/tests/test_agent_opencode.py b/tests/test_agent_opencode.py index 96c162dc..45c04acd 100644 --- a/tests/test_agent_opencode.py +++ b/tests/test_agent_opencode.py @@ -63,7 +63,6 @@ def test_requires_version_with_custom_provider_fetch(self, monkeypatch): def test_supported_version_needs_no_required_update(self, monkeypatch): monkeypatch.setattr(opencode, "agent_version", lambda _binary: "1.0.220") - assert opencode.required_update_message() is None assert opencode.minimum_version_error() is None diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 1b98ce57..3e84073b 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -530,13 +530,72 @@ def fake_run(args, **kwargs): "ucode.agents.prompt_yes_no", lambda prompt: (_ for _ in ()).throw(AssertionError("should not prompt")), ) - monkeypatch.setattr("ucode.agents._required_update_message", lambda _: None) monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) assert install_tool_binary("opencode", strict=False, update_existing=True) is True assert calls == [] assert "Updating OpenCode..." not in capsys.readouterr().out + @pytest.mark.parametrize( + ("tool", "display", "command"), + [ + ("claude", "Claude Code", ["claude", "upgrade"]), + ("codex", "Codex", ["codex", "update"]), + ], + ) + def test_blocked_native_tool_prompts_and_uses_agent_cli( + self, monkeypatch, tool, display, command + ): + calls: list[list[str]] = [] + prompts: list[str] = [] + + monkeypatch.setattr("ucode.agents.shutil.which", lambda binary: f"/usr/bin/{binary}") + monkeypatch.setattr( + "ucode.agents.subprocess.run", + lambda args, **kwargs: calls.append(args) or subprocess.CompletedProcess(args, 0), + ) + monkeypatch.setattr( + "ucode.agents.prompt_yes_no", lambda prompt: prompts.append(prompt) or True + ) + errors = iter(["must upgrade", None]) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: next(errors)) + + # Native minimum-version blockers are repaired even on an ordinary + # launch, where update_existing is false. + assert install_tool_binary(tool) is True + assert prompts == [f"Upgrade {display} if available?"] + assert calls == [command] + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_blocked_native_tool_decline_raises_without_command(self, monkeypatch, tool): + monkeypatch.setattr("ucode.agents.shutil.which", lambda binary: f"/usr/bin/{binary}") + monkeypatch.setattr("ucode.agents.prompt_yes_no", lambda _prompt: False) + monkeypatch.setattr( + "ucode.agents.subprocess.run", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("upgrade command should not run") + ), + ) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: "still blocked") + + with pytest.raises(RuntimeError, match="still blocked"): + install_tool_binary(tool) + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_unblocked_native_tool_does_not_check_or_prompt(self, monkeypatch, tool): + monkeypatch.setattr("ucode.agents.shutil.which", lambda binary: f"/usr/bin/{binary}") + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) + monkeypatch.setattr( + "ucode.agents.tool_update_available", + lambda _tool: (_ for _ in ()).throw(AssertionError("must not check npm")), + ) + monkeypatch.setattr( + "ucode.agents.prompt_yes_no", + lambda _prompt: (_ for _ in ()).throw(AssertionError("must not prompt")), + ) + + assert install_tool_binary(tool, update_existing=True) is True + def test_required_update_runs_even_when_optional_prompt_disabled(self, monkeypatch): """A required (minimum-version) update is forced regardless of the prompt_optional_updates preference.""" @@ -551,8 +610,8 @@ def fake_run(args, **kwargs): monkeypatch.setattr("ucode.agents.shutil.which", fake_which) monkeypatch.setattr("ucode.agents.subprocess.run", fake_run) - monkeypatch.setattr("ucode.agents._required_update_message", lambda _: "must upgrade") - monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) + errors = iter(["must upgrade", None]) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: next(errors)) assert ( install_tool_binary( diff --git a/tests/test_codex_smart_routing_v2.py b/tests/test_codex_smart_routing_v2.py index 58e72126..d6f81570 100644 --- a/tests/test_codex_smart_routing_v2.py +++ b/tests/test_codex_smart_routing_v2.py @@ -24,15 +24,6 @@ def test_smart_routing_switch_message_is_boxed(): class TestLaunchCodex: - def test_rejects_unsupported_codex_version(self, monkeypatch): - monkeypatch.setenv(v2.ENV_VAR, "1") - monkeypatch.setattr(codex, "clear_model_preferences", lambda state: False) - monkeypatch.setattr(codex, "agent_version", lambda binary: "0.144.0") - monkeypatch.setattr(v2, "launch_codex", lambda *args, **kwargs: pytest.fail("launched")) - - with pytest.raises(RuntimeError, match="requires Codex 0.145.0 or newer; found 0.144.0"): - codex.launch({"workspace": WS}, []) - def test_codex_launch_dispatches_when_flag_enabled(self, monkeypatch): calls = [] monkeypatch.setenv(v2.ENV_VAR, "1") diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9aea20ce..82c12dd5 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -111,7 +111,7 @@ def test_missing_binary_offers_install(self): assert checks[0].suggestion is not None def test_outdated_offers_update(self): - state = {"available_tools": ["claude"]} + state = {"available_tools": ["opencode"]} with ( patch.object(doctor_mod, "load_state", return_value=state), patch.object(doctor_mod, "tool_binary_installed", return_value=True), @@ -127,12 +127,31 @@ def test_up_to_date_is_ok(self): with ( patch.object(doctor_mod, "load_state", return_value=state), patch.object(doctor_mod, "tool_binary_installed", return_value=True), - patch.object(doctor_mod, "tool_update_available", return_value=None), + patch.object(doctor_mod, "tool_version_error", return_value=None), + patch.object( + doctor_mod, + "tool_update_available", + side_effect=AssertionError("native updater must not use npm detection"), + ), ): checks = _check_agent_clis() assert checks[0].status == "ok" + assert "agent CLI" in checks[0].detail assert checks[0].suggestion is None + def test_blocked_native_agent_offers_native_upgrade(self): + state = {"available_tools": ["claude"]} + with ( + patch.object(doctor_mod, "load_state", return_value=state), + patch.object(doctor_mod, "tool_binary_installed", return_value=True), + patch.object(doctor_mod, "tool_version_error", return_value="version too old"), + ): + checks = _check_agent_clis() + assert checks[0].status == "warn" + assert checks[0].detail == "version too old" + assert checks[0].suggestion is not None + assert checks[0].suggestion.prompt == "Upgrade Claude Code if available?" + def test_unknown_tool_is_skipped(self): state = {"available_tools": ["not-a-real-tool"]} with patch.object(doctor_mod, "load_state", return_value=state):