From 18f00dd68f5d1b60d58a31b9576cea4df145a90c Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 4 Sep 2026 17:35:26 +0000 Subject: [PATCH 1/6] ugprade cmds --- src/ucode/agents/__init__.py | 42 ++++++++++++++++++++++++++++------- src/ucode/doctor.py | 2 +- tests/test_agents_init.py | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 232c65cf..a18c18c9 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -73,6 +73,7 @@ DEFAULT_TOOL = "codex" BUNDLE_VERSION = 1 _MANAGED_SETTINGS_TOOLS = {"claude", "codex"} +_NATIVE_UPGRADE_TOOLS = {"claude", "codex"} # ucode tool -> `databricks aitools` agent id. gemini/pi aren't supported. AITOOLS_AGENT_TOKENS = { @@ -111,14 +112,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_TOOLS and version is None and shutil.which(binary): + command = [binary, "upgrade"] + 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 @@ -127,6 +132,24 @@ def _update_installed_tool_binary(tool: str, version: str | None = None) -> bool return bool(shutil.which(binary)) +def _maybe_upgrade_installed_tool(tool: str, *, prompt: bool) -> bool: + """Offer a published upgrade for CLIs that provide their own upgrader. + + npm remains a read-only source of installed/latest version information; + the actual upgrade is delegated to the agent CLI so its installation + method and release channel are preserved. + """ + if tool not in _NATIVE_UPGRADE_TOOLS: + return False + update = tool_update_available(tool) + if not update: + return False + _current, latest = update + if prompt and prompt_yes_no(f"Upgrade {TOOL_SPECS[tool]['display']} to {latest}?"): + _update_installed_tool_binary(tool) + return True + + def _minimum_version_error(tool: str) -> str | None: checker = getattr(_MODULES[tool], "minimum_version_error", None) if not callable(checker): @@ -191,10 +214,13 @@ def install_tool_binary( too_new = _maybe_downgrade_too_new_tool(tool, prompt=prompt_optional_updates) if update_existing and not too_new: + upgrade_detected = _maybe_upgrade_installed_tool(tool, prompt=prompt_optional_updates) required_update = _required_update_message(tool) - if required_update: + if required_update and not (upgrade_detected and prompt_optional_updates): # Required updates are forced regardless of prompt preference; - # the tool won't function on an unsupported version. + # the tool won't function on an unsupported version. When an + # interactive native upgrade was offered, declining it falls + # through to the actionable minimum-version error below. print_warning(required_update) if not _update_installed_tool_binary(tool): raise RuntimeError(_minimum_version_error(tool) or required_update) diff --git a/src/ucode/doctor.py b/src/ucode/doctor.py index 31d8bc39..2d4eab57 100644 --- a/src/ucode/doctor.py +++ b/src/ucode/doctor.py @@ -178,7 +178,7 @@ def _check_agent_clis() -> list[Check]: "warn", f"{current} installed; {latest} available", Suggestion( - f"Update {display} to {latest}?", lambda t=tool: update_tool_binary(t) + f"Upgrade {display} to {latest}?", lambda t=tool: update_tool_binary(t) ), ) ) diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 1b98ce57..aa68a1a2 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -537,6 +537,49 @@ def fake_run(args, **kwargs): assert calls == [] assert "Updating OpenCode..." not in capsys.readouterr().out + @pytest.mark.parametrize( + ("tool", "display", "latest"), + [ + ("claude", "Claude Code", "2.1.250"), + ("codex", "Codex", "0.150.0"), + ], + ) + def test_native_upgrade_prompts_and_uses_agent_cli(self, monkeypatch, tool, display, latest): + 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.tool_update_available", lambda _tool: ("old", latest)) + monkeypatch.setattr( + "ucode.agents.prompt_yes_no", lambda prompt: prompts.append(prompt) or True + ) + monkeypatch.setattr("ucode.agents._required_update_message", lambda _: None) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) + + assert install_tool_binary(tool, update_existing=True) is True + assert prompts == [f"Upgrade {display} to {latest}?"] + assert calls == [[tool, "upgrade"]] + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_native_upgrade_decline_does_not_run_command(self, monkeypatch, tool): + monkeypatch.setattr("ucode.agents.shutil.which", lambda binary: f"/usr/bin/{binary}") + monkeypatch.setattr("ucode.agents.tool_update_available", lambda _tool: ("old", "new")) + 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._required_update_message", lambda _: None) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) + + 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.""" From 61c615563a797c6c51a1bee5ed52831d96455fa0 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 4 Sep 2026 17:54:01 +0000 Subject: [PATCH 2/6] update --- src/ucode/agents/__init__.py | 52 +++++++++++++++++------------------- src/ucode/agents/codex.py | 18 +++++++++++++ src/ucode/doctor.py | 21 ++++++++++++++- tests/test_agent_codex.py | 17 ++++++++++++ tests/test_agents_init.py | 37 ++++++++++++++++++------- tests/test_doctor.py | 23 ++++++++++++++-- 6 files changed, 128 insertions(+), 40 deletions(-) diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index a18c18c9..ef26b83b 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -73,7 +73,10 @@ DEFAULT_TOOL = "codex" BUNDLE_VERSION = 1 _MANAGED_SETTINGS_TOOLS = {"claude", "codex"} -_NATIVE_UPGRADE_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 = { @@ -112,8 +115,8 @@ def _update_installed_tool_binary(tool: str, version: str | None = None) -> bool package = spec["package"] target = f"{package}@{version}" if version else package - if tool in _NATIVE_UPGRADE_TOOLS and version is None and shutil.which(binary): - command = [binary, "upgrade"] + 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.") @@ -132,24 +135,6 @@ def _update_installed_tool_binary(tool: str, version: str | None = None) -> bool return bool(shutil.which(binary)) -def _maybe_upgrade_installed_tool(tool: str, *, prompt: bool) -> bool: - """Offer a published upgrade for CLIs that provide their own upgrader. - - npm remains a read-only source of installed/latest version information; - the actual upgrade is delegated to the agent CLI so its installation - method and release channel are preserved. - """ - if tool not in _NATIVE_UPGRADE_TOOLS: - return False - update = tool_update_available(tool) - if not update: - return False - _current, latest = update - if prompt and prompt_yes_no(f"Upgrade {TOOL_SPECS[tool]['display']} to {latest}?"): - _update_installed_tool_binary(tool) - return True - - def _minimum_version_error(tool: str) -> str | None: checker = getattr(_MODULES[tool], "minimum_version_error", None) if not callable(checker): @@ -214,14 +199,15 @@ def install_tool_binary( too_new = _maybe_downgrade_too_new_tool(tool, prompt=prompt_optional_updates) if update_existing and not too_new: - upgrade_detected = _maybe_upgrade_installed_tool(tool, prompt=prompt_optional_updates) required_update = _required_update_message(tool) - if required_update and not (upgrade_detected and prompt_optional_updates): - # Required updates are forced regardless of prompt preference; - # the tool won't function on an unsupported version. When an - # interactive native upgrade was offered, declining it falls - # through to the actionable minimum-version error below. + if required_update: print_warning(required_update) + if ( + tool in _NATIVE_UPGRADE_COMMANDS + and prompt_optional_updates + and not prompt_yes_no(f"Upgrade {spec['display']} if available?") + ): + raise RuntimeError(_minimum_version_error(tool) or required_update) if not _update_installed_tool_binary(tool): raise RuntimeError(_minimum_version_error(tool) or required_update) version_error = _minimum_version_error(tool) @@ -277,6 +263,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() @@ -289,6 +277,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/codex.py b/src/ucode/agents/codex.py index 625329f3..b82681b8 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -106,6 +106,24 @@ def _installed_version_status() -> tuple[str, bool] | None: return version, parsed < MINIMUM_CODEX_VERSION +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 or parsed >= MINIMUM_ROUTING_CODEX_VERSION: + return None + return ( + "Codex smart routing requires Codex " + f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer; found {version}." + ) + + +def required_update_message() -> str | None: + return minimum_version_error() + + def _use_legacy_layout() -> bool: """Return True when the installed Codex CLI predates per-profile config files. diff --git a/src/ucode/doctor.py b/src/ucode/doctor.py index 2d4eab57..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: @@ -178,7 +197,7 @@ def _check_agent_clis() -> list[Check]: "warn", f"{current} installed; {latest} available", Suggestion( - f"Upgrade {display} to {latest}?", lambda t=tool: update_tool_binary(t) + f"Update {display} to {latest}?", lambda t=tool: update_tool_binary(t) ), ) ) diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index 2cd11dc3..ce3c4794 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -25,6 +25,23 @@ 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 + assert codex.required_update_message() == 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 + assert codex.required_update_message() 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_agents_init.py b/tests/test_agents_init.py index aa68a1a2..823dfdd4 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -538,13 +538,15 @@ def fake_run(args, **kwargs): assert "Updating OpenCode..." not in capsys.readouterr().out @pytest.mark.parametrize( - ("tool", "display", "latest"), + ("tool", "display", "command"), [ - ("claude", "Claude Code", "2.1.250"), - ("codex", "Codex", "0.150.0"), + ("claude", "Claude Code", ["claude", "upgrade"]), + ("codex", "Codex", ["codex", "update"]), ], ) - def test_native_upgrade_prompts_and_uses_agent_cli(self, monkeypatch, tool, display, latest): + def test_blocked_native_tool_prompts_and_uses_agent_cli( + self, monkeypatch, tool, display, command + ): calls: list[list[str]] = [] prompts: list[str] = [] @@ -553,21 +555,19 @@ def test_native_upgrade_prompts_and_uses_agent_cli(self, monkeypatch, tool, disp "ucode.agents.subprocess.run", lambda args, **kwargs: calls.append(args) or subprocess.CompletedProcess(args, 0), ) - monkeypatch.setattr("ucode.agents.tool_update_available", lambda _tool: ("old", latest)) monkeypatch.setattr( "ucode.agents.prompt_yes_no", lambda prompt: prompts.append(prompt) or True ) - monkeypatch.setattr("ucode.agents._required_update_message", lambda _: None) + monkeypatch.setattr("ucode.agents._required_update_message", lambda _: "must upgrade") monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) assert install_tool_binary(tool, update_existing=True) is True - assert prompts == [f"Upgrade {display} to {latest}?"] - assert calls == [[tool, "upgrade"]] + assert prompts == [f"Upgrade {display} if available?"] + assert calls == [command] @pytest.mark.parametrize("tool", ["claude", "codex"]) - def test_native_upgrade_decline_does_not_run_command(self, monkeypatch, tool): + 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.tool_update_available", lambda _tool: ("old", "new")) monkeypatch.setattr("ucode.agents.prompt_yes_no", lambda _prompt: False) monkeypatch.setattr( "ucode.agents.subprocess.run", @@ -575,8 +575,25 @@ def test_native_upgrade_decline_does_not_run_command(self, monkeypatch, tool): AssertionError("upgrade command should not run") ), ) + monkeypatch.setattr("ucode.agents._required_update_message", lambda _: "must upgrade") + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: "still blocked") + + with pytest.raises(RuntimeError, match="still blocked"): + install_tool_binary(tool, update_existing=True) + + @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._required_update_message", lambda _: None) 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 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): From 57753578f217e774419e6871915b147f6bf24fc5 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 4 Sep 2026 18:42:22 +0000 Subject: [PATCH 3/6] update --- src/ucode/agent_updates.py | 19 ++++++++++++++++- src/ucode/agents/__init__.py | 6 +++--- src/ucode/agents/claude.py | 41 ++++++------------------------------ src/ucode/agents/codex.py | 27 +++++++----------------- tests/test_agent_claude.py | 8 ------- tests/test_agent_codex.py | 2 -- tests/test_agent_updates.py | 18 +++++++++++++++- tests/test_agents_init.py | 5 +++++ 8 files changed, 58 insertions(+), 68 deletions(-) diff --git a/src/ucode/agent_updates.py b/src/ucode/agent_updates.py index e78a7ea1..3e97ddd2 100644 --- a/src/ucode/agent_updates.py +++ b/src/ucode/agent_updates.py @@ -1,4 +1,4 @@ -"""Update checks for npm-installed coding agent CLIs.""" +"""Shared coding-agent version checks and update helpers.""" from __future__ import annotations @@ -6,6 +6,7 @@ import re import shutil import subprocess +from collections.abc import Callable _BASE_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)") _STABLE_VERSION_RE = re.compile(r"v?\d+\.\d+\.\d+$") @@ -26,6 +27,22 @@ def _is_stable(value: str) -> bool: return bool(_STABLE_VERSION_RE.fullmatch(value.strip())) +def version_requirement_error( + installed: str, + minimum: tuple[int, int, int], + message: Callable[[str], str], +) -> str | None: + """Return an agent-specific error when ``installed`` is below ``minimum``. + + Unknown or unparsable versions remain non-blocking, matching the existing + agent behavior. ``message`` receives the installed version text. + """ + parsed = _base_version(installed) + if parsed is None or parsed >= minimum: + return None + return message(installed) + + def published_versions(package: str) -> list[str]: """Return every published version of an npm package, in npm's ascending order, or an empty list if the registry can't be reached.""" diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index ef26b83b..c7d41de7 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -144,9 +144,9 @@ def _minimum_version_error(tool: str) -> str | None: def _required_update_message(tool: str) -> str | None: checker = getattr(_MODULES[tool], "required_update_message", None) - if not callable(checker): - return None - return checker() + if callable(checker): + return checker() + return _minimum_version_error(tool) def _too_new_downgrade(tool: str) -> tuple[str, str] | None: diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 01b3a1f9..e26f035e 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -16,6 +16,7 @@ from typing import cast from ucode import gateway_proxy +from ucode.agent_updates import version_requirement_error from ucode.config_io import ( APP_DIR, ToolSpec, @@ -99,24 +100,6 @@ ) -def _parse_version(value: str) -> tuple[int, int, int] | None: - match = re.search(r"(\d+)\.(\d+)\.(\d+)", value) - if not match: - return None - major, minor, patch = match.groups() - 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,23 +109,13 @@ 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: - return None - version, is_too_old = status - if not is_too_old: + if os.environ.get(GATEWAY_MODEL_DISCOVERY_ENV_VAR) != "1" and not smart_routing_v2.enabled(): return None - return _minimum_version_requirement_message(version) + return version_requirement_error( + agent_version(SPEC["binary"]), + MINIMUM_CLAUDE_VERSION, + _minimum_version_requirement_message, + ) def _resolve_web_search_model(state: dict) -> str | None: diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index b82681b8..7f549bd8 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -14,6 +14,7 @@ import tomlkit from tomlkit.exceptions import ParseError +from ucode.agent_updates import version_requirement_error from ucode.codex_config import codex_config_args from ucode.config_io import ( APP_DIR, @@ -98,32 +99,20 @@ 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: - version = agent_version(SPEC["binary"]) - parsed = _parse_version(version) - if parsed is None: - return None - return version, parsed < MINIMUM_CODEX_VERSION - - 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 or parsed >= MINIMUM_ROUTING_CODEX_VERSION: - return None - return ( - "Codex smart routing requires Codex " - f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer; found {version}." + return version_requirement_error( + agent_version(SPEC["binary"]), + MINIMUM_ROUTING_CODEX_VERSION, + lambda version: ( + "Codex smart routing requires Codex " + f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer; found {version}." + ), ) -def required_update_message() -> str | None: - return minimum_version_error() - - def _use_legacy_layout() -> bool: """Return True when the installed Codex CLI predates per-profile config files. 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 ce3c4794..d30e4b2c 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -32,14 +32,12 @@ def test_smart_routing_old_version_requires_update(self, monkeypatch): expected = "Codex smart routing requires Codex 0.145.0 or newer; found 0.144.0." assert codex.minimum_version_error() == expected - assert codex.required_update_message() == 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 - assert codex.required_update_message() is None class TestHasUcodeConfig: diff --git a/tests/test_agent_updates.py b/tests/test_agent_updates.py index 230e1a01..17f6cef0 100644 --- a/tests/test_agent_updates.py +++ b/tests/test_agent_updates.py @@ -5,7 +5,9 @@ import json import subprocess -from ucode.agent_updates import latest_version_below, published_versions +import pytest + +from ucode.agent_updates import latest_version_below, published_versions, version_requirement_error _GEMINI_VERSIONS = [ "0.43.0", @@ -70,3 +72,17 @@ def test_falls_back_to_prerelease_when_no_stable(self, monkeypatch): def test_returns_none_when_nothing_qualifies(self, monkeypatch): _fake_published(monkeypatch, ["0.45.0", "0.46.0"]) assert latest_version_below("@google/gemini-cli", (0, 45, 0)) is None + + +class TestVersionRequirementError: + def test_formats_blocker_for_old_version(self): + assert ( + version_requirement_error( + "2.1.247", (2, 1, 248), lambda version: f"blocked at {version}" + ) + == "blocked at 2.1.247" + ) + + @pytest.mark.parametrize("version", ["2.1.248", "2.2.0", "unknown"]) + def test_supported_or_unknown_version_is_not_blocked(self, version): + assert version_requirement_error(version, (2, 1, 248), str) is None diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 823dfdd4..eebf87d5 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -95,6 +95,11 @@ def test_tool_update_available_uses_agent_override(self, monkeypatch): assert agents_mod.tool_update_available("opencode") == ("1.18.15", "1.18.16") + def test_required_update_defaults_to_minimum_version_blocker(self, monkeypatch): + monkeypatch.setattr(agents_mod.codex, "minimum_version_error", lambda: "too old") + + assert agents_mod._required_update_message("codex") == "too old" + class TestInstallAiToolsForAgents: def _capture(self, monkeypatch): From 1d9dd32a11f00128c3fe1f6ddda53e3af56dbe62 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 4 Sep 2026 18:47:54 +0000 Subject: [PATCH 4/6] Simplify minimum version upgrade checks --- src/ucode/agent_updates.py | 19 +------------------ src/ucode/agents/__init__.py | 32 ++++++++++++-------------------- src/ucode/agents/claude.py | 19 +++++++++++++------ src/ucode/agents/codex.py | 15 +++++++-------- src/ucode/agents/opencode.py | 4 ---- tests/test_agent_opencode.py | 1 - tests/test_agent_updates.py | 18 +----------------- tests/test_agents_init.py | 16 ++++------------ 8 files changed, 38 insertions(+), 86 deletions(-) diff --git a/src/ucode/agent_updates.py b/src/ucode/agent_updates.py index 3e97ddd2..e78a7ea1 100644 --- a/src/ucode/agent_updates.py +++ b/src/ucode/agent_updates.py @@ -1,4 +1,4 @@ -"""Shared coding-agent version checks and update helpers.""" +"""Update checks for npm-installed coding agent CLIs.""" from __future__ import annotations @@ -6,7 +6,6 @@ import re import shutil import subprocess -from collections.abc import Callable _BASE_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)") _STABLE_VERSION_RE = re.compile(r"v?\d+\.\d+\.\d+$") @@ -27,22 +26,6 @@ def _is_stable(value: str) -> bool: return bool(_STABLE_VERSION_RE.fullmatch(value.strip())) -def version_requirement_error( - installed: str, - minimum: tuple[int, int, int], - message: Callable[[str], str], -) -> str | None: - """Return an agent-specific error when ``installed`` is below ``minimum``. - - Unknown or unparsable versions remain non-blocking, matching the existing - agent behavior. ``message`` receives the installed version text. - """ - parsed = _base_version(installed) - if parsed is None or parsed >= minimum: - return None - return message(installed) - - def published_versions(package: str) -> list[str]: """Return every published version of an npm package, in npm's ascending order, or an empty list if the registry can't be reached.""" diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index c7d41de7..5ae6d641 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -142,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 callable(checker): - return checker() - return _minimum_version_error(tool) - - 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`.""" @@ -197,20 +190,19 @@ 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: - print_warning(required_update) - if ( - tool in _NATIVE_UPGRADE_COMMANDS - and prompt_optional_updates - and not prompt_yes_no(f"Upgrade {spec['display']} if available?") - ): - raise RuntimeError(_minimum_version_error(tool) or required_update) - if not _update_installed_tool_binary(tool): - raise RuntimeError(_minimum_version_error(tool) or required_update) version_error = _minimum_version_error(tool) + + if update_existing 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 diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index e26f035e..9b99a890 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -16,7 +16,6 @@ from typing import cast from ucode import gateway_proxy -from ucode.agent_updates import version_requirement_error from ucode.config_io import ( APP_DIR, ToolSpec, @@ -100,6 +99,14 @@ ) +def _parse_version(value: str) -> tuple[int, int, int] | None: + match = re.search(r"(\d+)\.(\d+)\.(\d+)", value) + if not match: + return None + major, minor, patch = match.groups() + return int(major), int(minor), int(patch) + + def _minimum_version_requirement_message(version: str) -> str: feature = "Smart routing" if smart_routing_v2.enabled() else "Model discovery" return ( @@ -111,11 +118,11 @@ def _minimum_version_requirement_message(version: str) -> str: def minimum_version_error() -> str | None: if os.environ.get(GATEWAY_MODEL_DISCOVERY_ENV_VAR) != "1" and not smart_routing_v2.enabled(): return None - return version_requirement_error( - agent_version(SPEC["binary"]), - MINIMUM_CLAUDE_VERSION, - _minimum_version_requirement_message, - ) + 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) def _resolve_web_search_model(state: dict) -> str | None: diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index 7f549bd8..f3ad7664 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -14,7 +14,6 @@ import tomlkit from tomlkit.exceptions import ParseError -from ucode.agent_updates import version_requirement_error from ucode.codex_config import codex_config_args from ucode.config_io import ( APP_DIR, @@ -103,13 +102,13 @@ def minimum_version_error() -> str | None: """Return the active smart-routing version blocker, if any.""" if not smart_routing_v2.enabled(): return None - return version_requirement_error( - agent_version(SPEC["binary"]), - MINIMUM_ROUTING_CODEX_VERSION, - lambda version: ( - "Codex smart routing requires Codex " - f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer; found {version}." - ), + version = agent_version(SPEC["binary"]) + parsed = _parse_version(version) + if parsed is None or parsed >= MINIMUM_ROUTING_CODEX_VERSION: + return None + return ( + "Codex smart routing requires Codex " + f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer; found {version}." ) 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/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_agent_updates.py b/tests/test_agent_updates.py index 17f6cef0..230e1a01 100644 --- a/tests/test_agent_updates.py +++ b/tests/test_agent_updates.py @@ -5,9 +5,7 @@ import json import subprocess -import pytest - -from ucode.agent_updates import latest_version_below, published_versions, version_requirement_error +from ucode.agent_updates import latest_version_below, published_versions _GEMINI_VERSIONS = [ "0.43.0", @@ -72,17 +70,3 @@ def test_falls_back_to_prerelease_when_no_stable(self, monkeypatch): def test_returns_none_when_nothing_qualifies(self, monkeypatch): _fake_published(monkeypatch, ["0.45.0", "0.46.0"]) assert latest_version_below("@google/gemini-cli", (0, 45, 0)) is None - - -class TestVersionRequirementError: - def test_formats_blocker_for_old_version(self): - assert ( - version_requirement_error( - "2.1.247", (2, 1, 248), lambda version: f"blocked at {version}" - ) - == "blocked at 2.1.247" - ) - - @pytest.mark.parametrize("version", ["2.1.248", "2.2.0", "unknown"]) - def test_supported_or_unknown_version_is_not_blocked(self, version): - assert version_requirement_error(version, (2, 1, 248), str) is None diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index eebf87d5..9dd5d585 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -95,11 +95,6 @@ def test_tool_update_available_uses_agent_override(self, monkeypatch): assert agents_mod.tool_update_available("opencode") == ("1.18.15", "1.18.16") - def test_required_update_defaults_to_minimum_version_blocker(self, monkeypatch): - monkeypatch.setattr(agents_mod.codex, "minimum_version_error", lambda: "too old") - - assert agents_mod._required_update_message("codex") == "too old" - class TestInstallAiToolsForAgents: def _capture(self, monkeypatch): @@ -535,7 +530,6 @@ 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 @@ -563,8 +557,8 @@ def test_blocked_native_tool_prompts_and_uses_agent_cli( monkeypatch.setattr( "ucode.agents.prompt_yes_no", lambda prompt: prompts.append(prompt) or True ) - 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(tool, update_existing=True) is True assert prompts == [f"Upgrade {display} if available?"] @@ -580,7 +574,6 @@ def test_blocked_native_tool_decline_raises_without_command(self, monkeypatch, t AssertionError("upgrade command should not run") ), ) - monkeypatch.setattr("ucode.agents._required_update_message", lambda _: "must upgrade") monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: "still blocked") with pytest.raises(RuntimeError, match="still blocked"): @@ -589,7 +582,6 @@ def test_blocked_native_tool_decline_raises_without_command(self, monkeypatch, t @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._required_update_message", lambda _: None) monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: None) monkeypatch.setattr( "ucode.agents.tool_update_available", @@ -616,8 +608,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( From bf6f6eb323928e85ee4ce42083fcb9dcdea45dfb Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 4 Sep 2026 18:52:36 +0000 Subject: [PATCH 5/6] Offer native upgrades on blocked launches --- src/ucode/agents/__init__.py | 3 ++- src/ucode/agents/codex.py | 8 -------- tests/test_agents_init.py | 6 ++++-- tests/test_codex_smart_routing_v2.py | 9 --------- 4 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 5ae6d641..7b31df3e 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -192,7 +192,8 @@ def install_tool_binary( too_new = _maybe_downgrade_too_new_tool(tool, prompt=prompt_optional_updates) version_error = _minimum_version_error(tool) - if update_existing and not too_new and version_error: + 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 diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index f3ad7664..064726dc 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -505,14 +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) if managed_model: diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 9dd5d585..3e84073b 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -560,7 +560,9 @@ def test_blocked_native_tool_prompts_and_uses_agent_cli( errors = iter(["must upgrade", None]) monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: next(errors)) - assert install_tool_binary(tool, update_existing=True) is True + # 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] @@ -577,7 +579,7 @@ def test_blocked_native_tool_decline_raises_without_command(self, monkeypatch, t monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: "still blocked") with pytest.raises(RuntimeError, match="still blocked"): - install_tool_binary(tool, update_existing=True) + install_tool_binary(tool) @pytest.mark.parametrize("tool", ["claude", "codex"]) def test_unblocked_native_tool_does_not_check_or_prompt(self, monkeypatch, tool): 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") From 36dab1e28d67fe8eb6387fd98f184ab053bc2734 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 4 Sep 2026 19:02:45 +0000 Subject: [PATCH 6/6] ruff --- src/ucode/agents/codex.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index 064726dc..7ba8be73 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -505,6 +505,7 @@ def launch(state: dict, tool_args: list[str]) -> None: binary = SPEC["binary"] workspace = state.get("workspace") if smart_routing_v2.enabled(): + def _app_server_start_model() -> str: managed_model = default_model(state) if managed_model: