Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 39 additions & 22 deletions src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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

Expand All @@ -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`."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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``."""
Expand Down
28 changes: 4 additions & 24 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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)

Expand Down
19 changes: 9 additions & 10 deletions src/ucode/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 0 additions & 4 deletions src/ucode/agents/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions src/ucode/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 0 additions & 8 deletions tests/test_agent_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -77,15 +71,13 @@ 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)
monkeypatch.delenv(claude.GATEWAY_MODEL_DISCOVERY_ENV_VAR, raising=False)
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:
Expand Down
15 changes: 15 additions & 0 deletions tests/test_agent_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 0 additions & 1 deletion tests/test_agent_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
65 changes: 62 additions & 3 deletions tests/test_agents_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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(
Expand Down
Loading
Loading