From 6a4910ce2781bd4094fb3700b4f6eaa48ae6d8ad Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 18 Sep 2026 21:47:52 +0000 Subject: [PATCH 1/6] Require minimum agent versions for all ug launches Enforce Claude Code >= 2.1.259 and Codex >= 0.145.0 unconditionally instead of only when smart routing or gateway model discovery is enabled, and drop the feature-specific message casing. The launch-time recheck in _launch_smart_routing is subsumed by the bootstrap gate. --- src/ucode/agents/claude.py | 22 +++++------------- src/ucode/agents/codex.py | 30 ++++++++---------------- tests/test_agent_claude.py | 34 +++------------------------- tests/test_agent_codex.py | 16 ++++++++----- tests/test_codex_smart_routing_v2.py | 9 -------- 5 files changed, 28 insertions(+), 83 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index 341fce33..ae981c54 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -89,8 +89,8 @@ CLAUDE_USER_SETTINGS_PATH = CLAUDE_CONFIG_DIR / "settings.json" CLAUDE_BACKUP_PATH = APP_DIR / "claude-ucode-settings.backup.json" WEB_SEARCH_MCP_STATE_KEY = "claude_web_search_mcp" -MINIMUM_CLAUDE_VERSION = (2, 1, 248) -MINIMUM_CLAUDE_VERSION_TEXT = "2.1.248" +MINIMUM_CLAUDE_VERSION = (2, 1, 259) +MINIMUM_CLAUDE_VERSION_TEXT = "2.1.259" # managedMcpServers needs Claude Code 2.1.259+; older versions ignore it and fall back to user scope. MANAGED_MCP_MIN_VERSION = (2, 1, 259) MANAGED_MCP_MIN_VERSION_TEXT = "2.1.259" @@ -116,25 +116,15 @@ def _parse_version(value: str) -> tuple[int, int, int] | None: return int(major), int(minor), int(patch) -def _minimum_version_requirement_message(version: str) -> str: - feature = "Smart routing" if smart_routing_v2.smart_routing_enabled() else "Model discovery" - return ( - f"{feature} requires Claude Code {MINIMUM_CLAUDE_VERSION_TEXT} or newer. " - f"Your current version is Claude Code {version}." - ) - - def minimum_version_error() -> str | None: - if ( - os.environ.get(GATEWAY_MODEL_DISCOVERY_ENV_VAR) != "1" - and not smart_routing_v2.smart_routing_enabled() - ): - return None 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) + return ( + f"ug requires Claude Code {MINIMUM_CLAUDE_VERSION_TEXT} or newer. " + f"Your current version is Claude Code {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 8f30d63b..704a3adc 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -102,10 +102,11 @@ MODEL_SERVICE_PARENT_SCHEMA_HEADER, ], ] -MINIMUM_CODEX_VERSION = (0, 134, 0) -MINIMUM_CODEX_VERSION_TEXT = "0.134.0" -MINIMUM_ROUTING_CODEX_VERSION = (0, 145, 0) -MINIMUM_ROUTING_CODEX_VERSION_TEXT = "0.145.0" +MINIMUM_CODEX_VERSION = (0, 145, 0) +MINIMUM_CODEX_VERSION_TEXT = "0.145.0" +# Codex 0.134.0 introduced per-profile config files; older releases use the legacy layout. +LEGACY_LAYOUT_CODEX_VERSION = (0, 134, 0) +LEGACY_LAYOUT_CODEX_VERSION_TEXT = "0.134.0" # Retained only to identify and remove state written by the legacy persisted opt-in. SMART_ROUTING_STATE_KEY = smart_routing_v2.LEGACY_STATE_KEY APP_SERVER_SMART_ROUTING_STARTING_MODEL = "gpt-5.6-luna" @@ -142,17 +143,11 @@ def _parse_version(value: str) -> tuple[int, int, int] | None: def minimum_version_error() -> str | None: - """Return the active smart-routing version blocker, if any.""" - if not smart_routing_v2.smart_routing_enabled(): - return None version = agent_version(SPEC["binary"]) parsed = _parse_version(version) - if parsed is None or parsed >= MINIMUM_ROUTING_CODEX_VERSION: + if parsed is None or parsed >= MINIMUM_CODEX_VERSION: return None - return ( - "Codex smart routing requires Codex " - f"{MINIMUM_ROUTING_CODEX_VERSION_TEXT} or newer; found {version}." - ) + return f"ug requires Codex {MINIMUM_CODEX_VERSION_TEXT} or newer; found {version}." def _use_legacy_layout() -> bool: @@ -167,7 +162,7 @@ def _use_legacy_layout() -> bool: parsed = _parse_version(agent_version(SPEC["binary"])) if parsed is None: return False - return parsed < MINIMUM_CODEX_VERSION + return parsed < LEGACY_LAYOUT_CODEX_VERSION def has_ucode_config() -> bool: @@ -943,7 +938,7 @@ def launch( if _use_legacy_layout(): print_warning_err( f"Codex {agent_version(binary)} is outdated. Upgrade Codex to " - f"{MINIMUM_CODEX_VERSION_TEXT} or newer, then run `codex --version` to verify " + f"{LEGACY_LAYOUT_CODEX_VERSION_TEXT} or newer, then run `codex --version` to verify " "the active installation." ) _run_codex( @@ -1010,13 +1005,6 @@ def launch( def _launch_smart_routing(state: dict, tool_args: list[str]) -> None: """Launch the Codex TUI through the smart-routing interposer.""" binary = SPEC["binary"] - 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}." - ) configured_model = _smart_routing_config_model(state) # Prefer the custom catalog if it exists. diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index c08f76a2..3ebe7309 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -46,52 +46,24 @@ def test_display(self): class TestMinimumVersion: - @pytest.mark.parametrize("version", ["2.1.248", "2.1.250", "3.0.0"]) + @pytest.mark.parametrize("version", ["2.1.259", "2.1.260", "3.0.0"]) def test_supported_version(self, monkeypatch, version): - monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") monkeypatch.setattr(claude, "agent_version", lambda _binary: version) assert claude.minimum_version_error() is None def test_older_version_requires_update(self, monkeypatch): - monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") - monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.1.247") + monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.1.258") assert claude.minimum_version_error() == ( - "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") - monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.1.247") - - expected = ( - "Model discovery requires Claude Code 2.1.248 or newer. " - "Your current version is Claude Code 2.1.247." + "ug requires Claude Code 2.1.259 or newer. Your current version is Claude Code 2.1.258." ) - assert claude.minimum_version_error() == expected - - def test_smart_routing_message_wins_when_both_features_are_enabled(self, monkeypatch): - monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") - monkeypatch.setenv(claude.GATEWAY_MODEL_DISCOVERY_ENV_VAR, "1") - monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.1.247") - - assert claude.minimum_version_error().startswith("Smart routing requires") def test_unknown_version_does_not_block(self, monkeypatch): - monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") monkeypatch.setattr(claude, "agent_version", lambda _binary: "unknown") assert claude.minimum_version_error() is None - def test_older_version_is_not_validated_without_discovery_features(self, monkeypatch): - monkeypatch.delenv(v2.ENABLE_SMART_ROUTING_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 - class TestRenderOverlay: def test_long_context_suffix_supports_major_only_claude_versions(self): diff --git a/tests/test_agent_codex.py b/tests/test_agent_codex.py index 2b16252d..008cd09e 100644 --- a/tests/test_agent_codex.py +++ b/tests/test_agent_codex.py @@ -29,16 +29,20 @@ def test_display(self): class TestMinimumVersion: - def test_smart_routing_old_version_requires_update(self, monkeypatch): - monkeypatch.setenv(codex.smart_routing_v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") + @pytest.mark.parametrize("version", ["0.145.0", "0.148.0", "1.0.0"]) + def test_supported_version(self, monkeypatch, version): + monkeypatch.setattr(codex, "agent_version", lambda _binary: version) + + assert codex.minimum_version_error() is None + + def test_older_version_requires_update(self, monkeypatch): 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." + expected = "ug 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.ENABLE_SMART_ROUTING_ENV_VAR, raising=False) - monkeypatch.setattr(codex, "agent_version", lambda _binary: "0.144.0") + def test_unknown_version_does_not_block(self, monkeypatch): + monkeypatch.setattr(codex, "agent_version", lambda _binary: "unknown") assert codex.minimum_version_error() is None diff --git a/tests/test_codex_smart_routing_v2.py b/tests/test_codex_smart_routing_v2.py index 7d8fa12a..1881f221 100644 --- a/tests/test_codex_smart_routing_v2.py +++ b/tests/test_codex_smart_routing_v2.py @@ -39,15 +39,6 @@ def test_smart_routing_switch_message_wraps_to_fixed_width(): class TestLaunchCodex: - def test_rejects_unsupported_codex_version(self, monkeypatch): - monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_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}, [], options=LaunchOptions(launch_smart_routing=True)) - @pytest.mark.parametrize( ("tool_args", "options"), [ From 181b525c70cff0ec232491051a60ac197816d4ee Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 18 Sep 2026 21:50:34 +0000 Subject: [PATCH 2/6] Drop the redundant MANAGED_MCP_MIN_VERSION check The 2.1.259 launch floor makes the managedMcpServers version probe always true, so managed_mcp_uses_managed_file no longer gates on the CLI version. --- src/ucode/agents/claude.py | 18 ++++-------------- tests/test_agent_claude.py | 9 +-------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/ucode/agents/claude.py b/src/ucode/agents/claude.py index ae981c54..216ccdc9 100644 --- a/src/ucode/agents/claude.py +++ b/src/ucode/agents/claude.py @@ -91,9 +91,6 @@ WEB_SEARCH_MCP_STATE_KEY = "claude_web_search_mcp" MINIMUM_CLAUDE_VERSION = (2, 1, 259) MINIMUM_CLAUDE_VERSION_TEXT = "2.1.259" -# managedMcpServers needs Claude Code 2.1.259+; older versions ignore it and fall back to user scope. -MANAGED_MCP_MIN_VERSION = (2, 1, 259) -MANAGED_MCP_MIN_VERSION_TEXT = "2.1.259" MANAGED_MCP_SETTINGS_KEY = "managedMcpServers" SPEC: ToolSpec = { @@ -695,24 +692,17 @@ def remove_claude_mcp_server(name: str, scope: str) -> bool: raise RuntimeError(f"Failed to remove MCP server '{name}' via claude CLI.") from exc -def _version_supports_managed_mcp() -> bool: - parsed = _parse_version(agent_version(SPEC["binary"])) - return parsed is not None and parsed >= MANAGED_MCP_MIN_VERSION - - def managed_mcp_uses_managed_file(workspace: str, *, use_pat: bool) -> bool: """Whether Claude's managed MCP servers belong in the OS-managed file rather than user scope. The OS-managed ``managedMcpServers`` key is additive (it never touches the developer's own - servers) but Claude Code reads it only from a real managed source, only since 2.1.259, and only - as a remote HTTP server it can drive OAuth against itself. So it fits only when the platform - supports the sudo reconcile, the run is interactive, the CLI is new enough, the developer is not - on PAT auth (which needs the stdio proxy), and the workspace publishes the ``claude-code`` OAuth - client. Every other case falls back to the user-scope registration.""" + servers) but Claude Code reads it only from a real managed source, and only as a remote HTTP + server it can drive OAuth against itself. So it fits only when the platform supports the sudo + reconcile, the run is interactive, the developer is not on PAT auth (which needs the stdio + proxy), and the workspace publishes the ``claude-code`` OAuth client. Every other case falls back to the user-scope registration.""" return ( managed_files_supported() and managed_writes_allowed() - and _version_supports_managed_mcp() and not use_pat and oauth_client_available(workspace, CLAUDE_CODE_OAUTH_CLIENT_ID) ) diff --git a/tests/test_agent_claude.py b/tests/test_agent_claude.py index 3ebe7309..0b3fdb3b 100644 --- a/tests/test_agent_claude.py +++ b/tests/test_agent_claude.py @@ -2320,22 +2320,15 @@ def test_reconfigure_does_not_back_up_generated_file(self, tmp_path, monkeypatch class TestManagedMcpUsesManagedFile: - def _wire( - self, monkeypatch, *, supported=True, interactive=True, version="2.1.259", oauth=True - ): + def _wire(self, monkeypatch, *, supported=True, interactive=True, oauth=True): monkeypatch.setattr(claude, "managed_files_supported", lambda: supported) monkeypatch.setattr(claude, "managed_writes_allowed", lambda: interactive) - monkeypatch.setattr(claude, "agent_version", lambda _binary: version) monkeypatch.setattr(claude, "oauth_client_available", lambda ws, client_id: oauth) def test_true_when_all_conditions_hold(self, monkeypatch): self._wire(monkeypatch) assert claude.managed_mcp_uses_managed_file(WS, use_pat=False) is True - def test_false_on_old_claude(self, monkeypatch): - self._wire(monkeypatch, version="2.1.258") - assert claude.managed_mcp_uses_managed_file(WS, use_pat=False) is False - def test_false_under_pat(self, monkeypatch): self._wire(monkeypatch) assert claude.managed_mcp_uses_managed_file(WS, use_pat=True) is False From df27ee51858485618c43206629e0e70893d95dfa Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 18 Sep 2026 21:54:23 +0000 Subject: [PATCH 3/6] Restore confirm prompt before required agent upgrades Below the version floor, ug again asks 'Upgrade if available?' before running the native upgrader (restores the flow removed in #638, now that the floor applies to every launch). Declining blocks the launch with the requirement error; EOF/piped runs take the default and upgrade so headless launches do not stall. npm-managed tools keep the silent auto-upgrade. --- src/ucode/agents/__init__.py | 7 ++++++ tests/test_agents_init.py | 44 ++++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/ucode/agents/__init__.py b/src/ucode/agents/__init__.py index 219f31a0..a70d93c3 100644 --- a/src/ucode/agents/__init__.py +++ b/src/ucode/agents/__init__.py @@ -35,6 +35,7 @@ print_success, print_warning, prompt_yes_no, + prompt_yes_no_default, spinner, ) @@ -201,6 +202,12 @@ def install_tool_binary( if not too_new and version_error: print_warning(version_error) + # Native upgraders run in place, so confirm before mutating the install; + # EOF/piped runs take the default and upgrade (a required fix must not stall). + if tool in _NATIVE_UPGRADE_COMMANDS and not prompt_yes_no_default( + f"Upgrade {spec['display']} if available?", default=True + ): + raise RuntimeError(version_error) if not _update_installed_tool_binary(tool): raise RuntimeError(version_error) version_error = _minimum_version_error(tool) diff --git a/tests/test_agents_init.py b/tests/test_agents_init.py index 5b775f18..99caa179 100644 --- a/tests/test_agents_init.py +++ b/tests/test_agents_init.py @@ -634,30 +634,66 @@ def fake_run(args, **kwargs): [ ("claude", ["claude", "upgrade"]), ("codex", ["codex", "update"]), - ("opencode", ["npm", "install", "-g", "opencode-ai@1"]), ], ) - def test_required_update_runs_without_prompt_and_rechecks(self, monkeypatch, tool, command): + def test_required_update_prompts_and_rechecks(self, monkeypatch, tool, command): calls = [] + prompts = [] monkeypatch.setattr("ucode.agents.shutil.which", lambda binary: f"/usr/bin/{binary}") + monkeypatch.setattr("ucode.agents._too_new_downgrade", lambda _: None) 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 _: pytest.fail("required upgrades must not prompt"), + "ucode.agents.prompt_yes_no_default", + lambda prompt, *, default: prompts.append((prompt, default)) or True, ) errors = iter(["must upgrade", None]) monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: next(errors)) assert install_tool_binary(tool) is True assert calls == [command] + assert prompts == [(f"Upgrade {TOOL_SPECS[tool]['display']} if available?", True)] + + @pytest.mark.parametrize("tool", ["claude", "codex"]) + def test_required_update_declined_blocks_launch(self, monkeypatch, tool): + monkeypatch.setattr("ucode.agents.shutil.which", lambda binary: f"/usr/bin/{binary}") + monkeypatch.setattr("ucode.agents._too_new_downgrade", lambda _: None) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: "must upgrade") + monkeypatch.setattr("ucode.agents.prompt_yes_no_default", lambda prompt, *, default: False) + monkeypatch.setattr( + "ucode.agents._update_installed_tool_binary", + lambda _: pytest.fail("declined upgrade must not run"), + ) + + with pytest.raises(RuntimeError, match="must upgrade"): + install_tool_binary(tool) + + def test_required_update_runs_without_prompt_for_npm_tools(self, monkeypatch): + calls = [] + monkeypatch.setattr("ucode.agents.shutil.which", lambda binary: f"/usr/bin/{binary}") + monkeypatch.setattr("ucode.agents._too_new_downgrade", lambda _: None) + monkeypatch.setattr( + "ucode.agents.subprocess.run", + lambda args, **kwargs: calls.append(args) or subprocess.CompletedProcess(args, 0), + ) + monkeypatch.setattr( + "ucode.agents.prompt_yes_no_default", + lambda *a, **k: pytest.fail("npm-tool upgrades must not prompt"), + ) + errors = iter(["must upgrade", None]) + monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: next(errors)) + + assert install_tool_binary("opencode") is True + assert calls == [["npm", "install", "-g", "opencode-ai@1"]] @pytest.mark.parametrize("update_succeeds", [False, True]) def test_required_update_must_clear_version_blocker(self, monkeypatch, update_succeeds): monkeypatch.setattr("ucode.agents.shutil.which", lambda binary: f"/usr/bin/{binary}") + monkeypatch.setattr("ucode.agents._too_new_downgrade", lambda _: None) monkeypatch.setattr("ucode.agents._minimum_version_error", lambda _: "still too old") + monkeypatch.setattr("ucode.agents.prompt_yes_no_default", lambda prompt, *, default: True) monkeypatch.setattr("ucode.agents._update_installed_tool_binary", lambda _: update_succeeds) with pytest.raises(RuntimeError, match="still too old"): From 429bb7d8cec9f7a211bcee55729db02c2b638824 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 18 Sep 2026 22:12:20 +0000 Subject: [PATCH 4/6] Add integration coverage for the launch version floor Drives the real installed CLI with below-floor agent pins (claude 2.1.258, codex 0.144.0): declining the required-upgrade prompt blocks the launch with the requirement restated; accepting runs the native upgrader and the floor clears only when the upgrade succeeds. The scenarios skip when the pinned agent already meets the floor, so the default CI version pins are unaffected. Verified with scripts/run_integration.py --installation-only under both pin sets. --- tests/integration/test_ug_version_floor.py | 170 +++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 tests/integration/test_ug_version_floor.py diff --git a/tests/integration/test_ug_version_floor.py b/tests/integration/test_ug_version_floor.py new file mode 100644 index 00000000..bcb42625 --- /dev/null +++ b/tests/integration/test_ug_version_floor.py @@ -0,0 +1,170 @@ +"""Version-floor gate: below-floor agent CLIs hit the required-upgrade prompt at launch. + +These scenarios exercise the launch-time minimum-version floor end to end. They +only do meaningful work when the runner pins a below-floor agent version, e.g. +`--claude-version 2.1.258 --codex-version 0.144.0` (CI: the workflow_dispatch +`claude_version` / `codex_version` inputs). With an at-floor-or-newer agent the +floor never engages and the scenario skips. +""" + +from __future__ import annotations + +import re +import subprocess +import time + +import pytest +from utils.terminal import TerminalProcess + +pytestmark = pytest.mark.installation + +_FLOORS = {"claude": (2, 1, 259), "codex": (0, 145, 0)} +_REQUIREMENTS = { + "claude": "ug requires Claude Code 2.1.259 or newer", + "codex": "ug requires Codex 0.145.0 or newer", +} +_UPGRADE_PROMPTS = { + "claude": "Upgrade Claude Code if available?", + "codex": "Upgrade Codex if available?", +} +_UPGRADE_NOTES = {"claude": "Upgrading Claude Code", "codex": "Upgrading Codex"} +_UPGRADE_FAILURES = { + "claude": "Could not update Claude Code", + "codex": "Could not update Codex", +} +# Shown by the auto-configure flow once the launch clears the floor gate. +_PAST_GATE_SIGNALS = ("Loading Databricks workspaces", "Workspace URL") + + +def _installed_version(session, binary: str) -> tuple[int, int, int]: + result = subprocess.run( + [binary, "--version"], + env=session.env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout + result.stderr) + assert match, f"`{binary} --version` reported no semver: {result!r}" + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def _require_below_floor(session, agent: str) -> None: + version = _installed_version(session, agent) + floor = _FLOORS[agent] + if version >= floor: + pytest.skip( + f"{agent} {'.'.join(map(str, version))} meets the floor; " + "pin a below-floor version to exercise the gate" + ) + + +def _wait_for_exit(tui: TerminalProcess, timeout: int = 60) -> None: + deadline = time.monotonic() + timeout + while not tui.ended and time.monotonic() < deadline: + tui.read() + assert tui.ended, f"Process did not exit within {timeout}s:\n{tui.visible}" + tui.child.close(force=False) + + +def _spawn_launch(session, agent: str, name: str) -> TerminalProcess: + tui = TerminalProcess(session, agent, [str(session.binary), agent], name) + tui.wait_for(lambda text: _UPGRADE_PROMPTS[agent] in text, "required-upgrade prompt") + return tui + + +def _assert_accept_outcome(tui: TerminalProcess, agent: str) -> None: + """After acceptance, the upgrader runs and the floor clears only on success. + + A working upgrader carries the launch past the gate to workspace + configuration; a failed one (e.g. registry unreachable) must leave the + launch blocked with the requirement restated. + """ + + def past_gate(text): + return any(signal in text for signal in _PAST_GATE_SIGNALS) + + def failed(text): + return _UPGRADE_FAILURES[agent] in text + + tui.wait_for( + lambda text: past_gate(text) or failed(text) or tui.ended, + "upgrade outcome", + timeout=600, + ) + transcript = "".join(tui.output) + if failed(transcript): + _wait_for_exit(tui) + assert _REQUIREMENTS[agent] in transcript + assert tui.child.exitstatus != 0 + else: + assert past_gate(transcript), f"Launch stalled after upgrade:\n{tui.visible}" + + +@pytest.mark.claude +def test_below_floor_claude_decline_blocks_launch(session): + """Scenario: launch Claude Code pinned below the version floor and decline the upgrade. + + Expected: the requirement warning and upgrade prompt appear, no upgrade is + attempted, and the launch exits nonzero without reaching the agent. + """ + _require_below_floor(session, "claude") + with _spawn_launch(session, "claude", "floor-decline") as tui: + tui.send("n\r", "decline the required upgrade") + _wait_for_exit(tui) + transcript = "".join(tui.output) + assert _REQUIREMENTS["claude"] in transcript + assert _UPGRADE_NOTES["claude"] not in transcript + assert tui.child.exitstatus != 0 + + +@pytest.mark.claude +def test_below_floor_claude_accept_upgrades_past_the_gate(session): + """Scenario: launch Claude Code pinned below the floor and accept the upgrade. + + Expected: the native upgrader runs; a successful upgrade carries the launch + past the gate to workspace configuration, while a failed one leaves the + launch blocked with the requirement restated. + """ + _require_below_floor(session, "claude") + with _spawn_launch(session, "claude", "floor-accept") as tui: + tui.send("y\r", "accept the required upgrade") + tui.wait_for( + lambda text: _UPGRADE_NOTES["claude"] in text, "native upgrade attempt", timeout=60 + ) + _assert_accept_outcome(tui, "claude") + + +@pytest.mark.codex +def test_below_floor_codex_decline_blocks_launch(session): + """Scenario: launch Codex pinned below the version floor and decline the upgrade. + + Expected: the requirement warning and upgrade prompt appear, no upgrade is + attempted, and the launch exits nonzero without reaching the agent. + """ + _require_below_floor(session, "codex") + with _spawn_launch(session, "codex", "floor-decline") as tui: + tui.send("n\r", "decline the required upgrade") + _wait_for_exit(tui) + transcript = "".join(tui.output) + assert _REQUIREMENTS["codex"] in transcript + assert _UPGRADE_NOTES["codex"] not in transcript + assert tui.child.exitstatus != 0 + + +@pytest.mark.codex +def test_below_floor_codex_accept_upgrades_past_the_gate(session): + """Scenario: launch Codex pinned below the floor and accept the upgrade. + + Expected: the native upgrader runs; a successful upgrade carries the launch + past the gate to workspace configuration, while a failed one leaves the + launch blocked with the requirement restated. + """ + _require_below_floor(session, "codex") + with _spawn_launch(session, "codex", "floor-accept") as tui: + tui.send("y\r", "accept the required upgrade") + tui.wait_for( + lambda text: _UPGRADE_NOTES["codex"] in text, "native upgrade attempt", timeout=60 + ) + _assert_accept_outcome(tui, "codex") From b51fe7da4342b6b358fa5eaeccb54073f9274914 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 18 Sep 2026 22:30:04 +0000 Subject: [PATCH 5/6] Simplify version-floor integration tests to one scenario per agent CI rejected the skip-based selection: the integration contract bans pytest.skip and the runner fails when selected tests skip. Each agent now has a single below-floor scenario (decline blocks, accept runs the native upgrader and the floor clears only on success) that returns early when the pinned CLI already meets the floor. --- tests/integration/test_ug_version_floor.py | 119 +++++++-------------- 1 file changed, 39 insertions(+), 80 deletions(-) diff --git a/tests/integration/test_ug_version_floor.py b/tests/integration/test_ug_version_floor.py index bcb42625..78df6815 100644 --- a/tests/integration/test_ug_version_floor.py +++ b/tests/integration/test_ug_version_floor.py @@ -1,10 +1,10 @@ -"""Version-floor gate: below-floor agent CLIs hit the required-upgrade prompt at launch. +"""Version-floor gate: ug enforces minimum agent CLI versions at launch. -These scenarios exercise the launch-time minimum-version floor end to end. They -only do meaningful work when the runner pins a below-floor agent version, e.g. -`--claude-version 2.1.258 --codex-version 0.144.0` (CI: the workflow_dispatch -`claude_version` / `codex_version` inputs). With an at-floor-or-newer agent the -floor never engages and the scenario skips. +One scenario per agent, driven against the real installed CLI with a +below-floor pin (`--claude-version 2.1.258 --codex-version 0.144.0`, or the +matching workflow_dispatch inputs). The gate only exists below the floor, and +the suite forbids skips, so each scenario returns early when the pinned CLI +already meets the floor. """ from __future__ import annotations @@ -33,7 +33,7 @@ "codex": "Could not update Codex", } # Shown by the auto-configure flow once the launch clears the floor gate. -_PAST_GATE_SIGNALS = ("Loading Databricks workspaces", "Workspace URL") +_PAST_GATE_SIGNALS = ("Loading Databricks workspaces", "Workspace URL", "Select workspace:") def _installed_version(session, binary: str) -> tuple[int, int, int]: @@ -50,16 +50,6 @@ def _installed_version(session, binary: str) -> tuple[int, int, int]: return int(match.group(1)), int(match.group(2)), int(match.group(3)) -def _require_below_floor(session, agent: str) -> None: - version = _installed_version(session, agent) - floor = _FLOORS[agent] - if version >= floor: - pytest.skip( - f"{agent} {'.'.join(map(str, version))} meets the floor; " - "pin a below-floor version to exercise the gate" - ) - - def _wait_for_exit(tui: TerminalProcess, timeout: int = 60) -> None: deadline = time.monotonic() + timeout while not tui.ended and time.monotonic() < deadline: @@ -68,103 +58,72 @@ def _wait_for_exit(tui: TerminalProcess, timeout: int = 60) -> None: tui.child.close(force=False) -def _spawn_launch(session, agent: str, name: str) -> TerminalProcess: +def _past_gate(text: str) -> bool: + return any(signal in text for signal in _PAST_GATE_SIGNALS) + + +def _launch(session, agent: str, name: str) -> TerminalProcess: tui = TerminalProcess(session, agent, [str(session.binary), agent], name) tui.wait_for(lambda text: _UPGRADE_PROMPTS[agent] in text, "required-upgrade prompt") return tui -def _assert_accept_outcome(tui: TerminalProcess, agent: str) -> None: - """After acceptance, the upgrader runs and the floor clears only on success. +def _assert_gate_holds_after_failed_upgrade(tui: TerminalProcess, agent: str) -> None: + """The floor clears only when the accepted upgrade actually succeeds. A working upgrader carries the launch past the gate to workspace configuration; a failed one (e.g. registry unreachable) must leave the launch blocked with the requirement restated. """ - - def past_gate(text): - return any(signal in text for signal in _PAST_GATE_SIGNALS) - - def failed(text): - return _UPGRADE_FAILURES[agent] in text - tui.wait_for( - lambda text: past_gate(text) or failed(text) or tui.ended, + lambda text: _past_gate(text) or _UPGRADE_FAILURES[agent] in text or tui.ended, "upgrade outcome", timeout=600, ) transcript = "".join(tui.output) - if failed(transcript): + if _UPGRADE_FAILURES[agent] in transcript: _wait_for_exit(tui) assert _REQUIREMENTS[agent] in transcript assert tui.child.exitstatus != 0 else: - assert past_gate(transcript), f"Launch stalled after upgrade:\n{tui.visible}" - + assert _past_gate(transcript), f"Launch stalled after upgrade:\n{tui.visible}" -@pytest.mark.claude -def test_below_floor_claude_decline_blocks_launch(session): - """Scenario: launch Claude Code pinned below the version floor and decline the upgrade. - Expected: the requirement warning and upgrade prompt appear, no upgrade is - attempted, and the launch exits nonzero without reaching the agent. - """ - _require_below_floor(session, "claude") - with _spawn_launch(session, "claude", "floor-decline") as tui: +def _check_version_floor(session, agent: str) -> None: + if _installed_version(session, agent) >= _FLOORS[agent]: + return # The gate only exists below the floor; skips are forbidden here. + with _launch(session, agent, "floor-decline") as tui: tui.send("n\r", "decline the required upgrade") _wait_for_exit(tui) transcript = "".join(tui.output) - assert _REQUIREMENTS["claude"] in transcript - assert _UPGRADE_NOTES["claude"] not in transcript + assert _REQUIREMENTS[agent] in transcript + assert _UPGRADE_NOTES[agent] not in transcript assert tui.child.exitstatus != 0 - - -@pytest.mark.claude -def test_below_floor_claude_accept_upgrades_past_the_gate(session): - """Scenario: launch Claude Code pinned below the floor and accept the upgrade. - - Expected: the native upgrader runs; a successful upgrade carries the launch - past the gate to workspace configuration, while a failed one leaves the - launch blocked with the requirement restated. - """ - _require_below_floor(session, "claude") - with _spawn_launch(session, "claude", "floor-accept") as tui: + with _launch(session, agent, "floor-accept") as tui: tui.send("y\r", "accept the required upgrade") tui.wait_for( - lambda text: _UPGRADE_NOTES["claude"] in text, "native upgrade attempt", timeout=60 + lambda text: _UPGRADE_NOTES[agent] in text, "native upgrade attempt", timeout=60 ) - _assert_accept_outcome(tui, "claude") + _assert_gate_holds_after_failed_upgrade(tui, agent) -@pytest.mark.codex -def test_below_floor_codex_decline_blocks_launch(session): - """Scenario: launch Codex pinned below the version floor and decline the upgrade. +@pytest.mark.claude +def test_claude_launch_below_version_floor(session): + """Scenario: launch Claude Code pinned below the version floor. - Expected: the requirement warning and upgrade prompt appear, no upgrade is - attempted, and the launch exits nonzero without reaching the agent. + Expected: ug requires an upgrade first. Declining blocks the launch with the + requirement restated; accepting runs the native upgrader and the floor + clears only when the upgrade succeeds. """ - _require_below_floor(session, "codex") - with _spawn_launch(session, "codex", "floor-decline") as tui: - tui.send("n\r", "decline the required upgrade") - _wait_for_exit(tui) - transcript = "".join(tui.output) - assert _REQUIREMENTS["codex"] in transcript - assert _UPGRADE_NOTES["codex"] not in transcript - assert tui.child.exitstatus != 0 + _check_version_floor(session, "claude") @pytest.mark.codex -def test_below_floor_codex_accept_upgrades_past_the_gate(session): - """Scenario: launch Codex pinned below the floor and accept the upgrade. +def test_codex_launch_below_version_floor(session): + """Scenario: launch Codex pinned below the version floor. - Expected: the native upgrader runs; a successful upgrade carries the launch - past the gate to workspace configuration, while a failed one leaves the - launch blocked with the requirement restated. + Expected: ug requires an upgrade first. Declining blocks the launch with the + requirement restated; accepting runs the native upgrader and the floor + clears only when the upgrade succeeds. """ - _require_below_floor(session, "codex") - with _spawn_launch(session, "codex", "floor-accept") as tui: - tui.send("y\r", "accept the required upgrade") - tui.wait_for( - lambda text: _UPGRADE_NOTES["codex"] in text, "native upgrade attempt", timeout=60 - ) - _assert_accept_outcome(tui, "codex") + _check_version_floor(session, "codex") From d7c2fe5e32a3248419674d662fd85a9aa5717262 Mon Sep 17 00:00:00 2001 From: Lilly Luo Date: Fri, 18 Sep 2026 23:16:48 +0000 Subject: [PATCH 6/6] Retrigger CI