From e461896a80f78310e4f63f556abb0ee79e3939bf Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:31:45 -0700 Subject: [PATCH 1/2] feat(acp): let an agent opt into the latest adapter release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New models ship inside an adapter's bundled runtime, so a pinned adapter can trail what the vendor already serves: claude-fable-5-1 is live, but through the pinned claude-agent-acp (whose SDK bundles Claude Code 2.1.232) every request answers "API Error: 400 Claude Code 2.1.232 does not support this model; version 2.1.251 or newer is required." The model cannot appear in codeg until the pin moves. A user who accepts the risk should be able to follow the newest release themselves, per agent, without waiting. "Adapter version" is a per-agent control in Agent Settings, npx agents only (a binary or uvx install has no npm dist-tag to track). It rides the same `env_json` store as pi's runtime override and the host-tools knob — a `CODEG_ADAPTER_CHANNEL=latest` reserved key, edited into the env draft and persisted by the same Save button — so both runtimes and both readers resolve it through layers that already exist. Pinned stays the default and is labeled recommended; the copy under the control says plainly that new releases are unreviewed and can break the agent, and that the newest release is occasionally known broken (Kimi Code 0.37.x took every session down while it was the newest). The channel is consulted at install and upgrade time only. On `latest` the prepare path tries `@latest` first, with the same npm flags every agent install already gets (`--include=optional` for platform optional deps, `--registry` past lagging mirrors); if that fails — npm unreachable, a mirror not yet carrying the tag's target — it says so in the install log and retries the reviewed pinned spec, so the agent still installs rather than not at all. A launch never consults npm: it runs whatever is installed, and nothing polls in the background. The recorded installed version keeps coming from the real post-install probe, so Version Status reports what actually landed, never what was asked for — and on the latest channel the pass state keeps the Upgrade action available, because the compare-to-pin flow cannot know whether npm has something newer. An explicit Custom install version wins on either channel and never falls back: the user asked for that exact version, and quietly installing a different one would relabel their choice. --- src-tauri/src/commands/acp.rs | 195 +++++++++++++++++- .../settings/acp-agent-settings.test.tsx | 118 +++++++++++ .../settings/acp-agent-settings.tsx | 139 +++++++++++++ src/i18n/messages/ar.json | 10 +- src/i18n/messages/de.json | 10 +- src/i18n/messages/en.json | 10 +- src/i18n/messages/es.json | 10 +- src/i18n/messages/fr.json | 10 +- src/i18n/messages/ja.json | 10 +- src/i18n/messages/ko.json | 10 +- src/i18n/messages/pt.json | 10 +- src/i18n/messages/zh-CN.json | 10 +- src/i18n/messages/zh-TW.json | 10 +- 13 files changed, 531 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index e551d751b5..30d9a22881 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -190,6 +190,57 @@ fn apply_custom_version_to_url(url: &str, registry_version: &str, custom_version url.replace(registry_version, custom_version) } +/// Per-agent `env_json` key that opts an npx agent into installing the +/// package's `latest` npm dist-tag instead of the reviewed registry pin. +/// Owned by the "Adapter version" control in Agent Settings, riding the same +/// per-agent env store as pi's `PI_ACP_PI_COMMAND` runtime override and the +/// host-tools knob. Consulted at install/upgrade time ONLY: a launch always +/// runs whatever is installed, and nothing polls npm in the background. +/// +/// Exactly the value `latest` opts in; absence or any other value stays on the +/// pin. Unlike `CODEG_ACP_HOST_TOOLS` there is no process-env second layer to +/// make "absent" ambiguous, so the settings control may delete the key for the +/// pinned default — both readers (this one and `adapterChannelFromEnvText` in +/// acp-agent-settings.tsx) treat absent as pinned. +pub(crate) const ADAPTER_CHANNEL_ENV: &str = "CODEG_ADAPTER_CHANNEL"; +const ADAPTER_CHANNEL_LATEST: &str = "latest"; + +/// Whether a resolved per-agent env opts into the `latest` adapter channel. +/// Takes the MERGED env (`build_runtime_env_from_setting`) rather than raw +/// `env_json`, so it reads the same layers the launch path and the settings +/// page display — a value set through the agent's local config file counts too. +fn adapter_channel_is_latest(env: &BTreeMap) -> bool { + env.get(ADAPTER_CHANNEL_ENV) + .is_some_and(|value| value.trim() == ADAPTER_CHANNEL_LATEST) +} + +/// The npm install spec(s) one prepare call will attempt, in order: the spec to +/// try first, plus the fallback to retry on failure (at most one). +/// +/// An explicit `version_override` (the Custom install dialog) always wins and +/// never falls back — the user asked for that exact version, and quietly +/// installing a different one would relabel their choice. With no override, a +/// latest-channel agent tries the `latest` dist-tag first and keeps the pinned +/// registry spec as the fallback, so npm being unreachable (or a mirror not +/// yet carrying the tag's target) degrades to the reviewed pin instead of a +/// failed install. The default stays byte-identical to `build_npm_install_spec`. +fn npm_install_attempts( + package: &str, + version_override: Option<&str>, + latest_channel: bool, +) -> Result<(String, Option), AcpError> { + let pinned = build_npm_install_spec(package, version_override)?; + let overridden = version_override.is_some_and(|raw| !raw.trim().is_empty()); + if latest_channel && !overridden { + let latest = format!( + "{}@{ADAPTER_CHANNEL_LATEST}", + package_name_from_spec(package) + ); + return Ok((latest, Some(pinned))); + } + Ok((pinned, None)) +} + /// Check whether an NPX agent command is spawnable. /// Uses PATH first, then falls back to the current npm global prefix to handle /// GUI environments that don't inherit the user's shell PATH. @@ -11969,10 +12020,6 @@ pub(crate) async fn acp_prepare_npx_agent_core( let meta = registry::get_agent_meta(agent_type); let result = match meta.distribution { registry::AgentDistribution::Npx { package, cmd, .. } => { - // `version_override` of None/empty keeps the registry-pinned spec; - // a custom version installs `@` instead. - let install_spec = build_npm_install_spec(package, version_override.as_deref())?; - let default = agent_setting_service::AgentDefaultInput { agent_type, registry_id: registry::registry_id_for(agent_type).to_string(), @@ -11982,11 +12029,25 @@ pub(crate) async fn acp_prepare_npx_agent_core( .await .map_err(|e| AcpError::protocol(e.to_string()))?; - let existing = agent_setting_service::get_by_agent_type(&db.conn, agent_type) + let setting = agent_setting_service::get_by_agent_type(&db.conn, agent_type) .await .ok() - .flatten() - .and_then(|m| m.installed_version); + .flatten(); + let existing = setting.as_ref().and_then(|m| m.installed_version.clone()); + // The latest-channel opt-in reads the same merged env layers the + // launch and the settings page resolve, so the control can never + // show one channel while the install applies another. + let latest_channel = adapter_channel_is_latest(&build_runtime_env_from_setting( + agent_type, + setting.as_ref(), + load_agent_local_config_json(agent_type).as_deref(), + )); + // `version_override` of None/empty keeps the channel's spec (the + // registry pin, or `@latest` for a latest-channel agent); a + // custom version installs `@` instead, on either + // channel. + let (first_spec, fallback_spec) = + npm_install_attempts(package, version_override.as_deref(), latest_channel)?; // Best-effort uninstall before reinstall. Forces npm to re-resolve // the dependency graph from scratch, which is required for @@ -12016,11 +12077,58 @@ pub(crate) async fn acp_prepare_npx_agent_core( emitter, &task_id, AgentInstallEventKind::Log, - format!("Installing {} ({install_spec})", meta.name), + format!("Installing {} ({first_spec})", meta.name), ); - install_npm_global_package_streaming(&install_spec, &task_id, emitter) - .await - .map_err(|e| annotate_npm_bootstrap_failure(&install_spec, e))?; + let install_spec = match install_npm_global_package_streaming( + &first_spec, + &task_id, + emitter, + ) + .await + { + Ok(()) => first_spec, + Err(err) => { + // FAIL SAFE TO THE PIN. A latest-channel install can die on + // things the pin does not (npm unreachable, a mirror not yet + // carrying the tag's target, a yanked release), and the user + // asked for "newest when possible", not "nothing unless + // newest". Retry the reviewed pinned spec, saying so in the + // same install log — and let the recorded installed version + // report what actually landed. + let Some(pinned_spec) = fallback_spec else { + return Err(annotate_npm_bootstrap_failure(&first_spec, err)); + }; + let err = annotate_npm_bootstrap_failure(&first_spec, err); + tracing::warn!( + "[acp] latest install {first_spec} failed ({err}); \ + falling back to pinned {pinned_spec}" + ); + emit_agent_install_event( + emitter, + &task_id, + AgentInstallEventKind::Log, + format!("ERROR: installing {first_spec} failed: {err}"), + ); + emit_agent_install_event( + emitter, + &task_id, + AgentInstallEventKind::Log, + format!( + "Falling back to the pinned version ({pinned_spec})..." + ), + ); + emit_agent_install_event( + emitter, + &task_id, + AgentInstallEventKind::Log, + format!("Installing {} ({pinned_spec})", meta.name), + ); + install_npm_global_package_streaming(&pinned_spec, &task_id, emitter) + .await + .map_err(|e| annotate_npm_bootstrap_failure(&pinned_spec, e))?; + pinned_spec + } + }; // For a bootstrap-wrapper package (hermes-agent), npm metadata // existing does NOT mean the agent can run: a skipped or broken @@ -15940,6 +16048,71 @@ wire_api = "chat" assert!(build_npm_install_spec("cline@3.0.9", Some("latest")).is_err()); } + // The pinned default is byte-identical to what `build_npm_install_spec` + // produced before the channel existed, with no fallback attempt. + #[test] + fn npm_install_attempts_defaults_to_the_pinned_spec() { + assert_eq!( + npm_install_attempts("@google/gemini-cli@0.44.1", None, false).unwrap(), + ("@google/gemini-cli@0.44.1".to_string(), None) + ); + assert_eq!( + npm_install_attempts("@google/gemini-cli@0.44.1", Some(" "), false).unwrap(), + ("@google/gemini-cli@0.44.1".to_string(), None) + ); + } + + // The latest channel tries the `latest` dist-tag first and keeps the + // registry pin as the fallback, so a failed latest install degrades to the + // reviewed version instead of no install at all. + #[test] + fn npm_install_attempts_maps_latest_channel_onto_the_dist_tag() { + assert_eq!( + npm_install_attempts("@google/gemini-cli@0.44.1", None, true).unwrap(), + ( + "@google/gemini-cli@latest".to_string(), + Some("@google/gemini-cli@0.44.1".to_string()) + ) + ); + // A blank override is the same as none. + assert_eq!( + npm_install_attempts("cline@3.0.9", Some(" "), true).unwrap(), + ("cline@latest".to_string(), Some("cline@3.0.9".to_string())) + ); + } + + // An explicit custom version wins on either channel and never falls back: + // the user asked for that exact version, and quietly installing another + // would relabel their choice. + #[test] + fn npm_install_attempts_lets_an_explicit_override_win() { + assert_eq!( + npm_install_attempts("cline@3.0.9", Some("2.0.0"), true).unwrap(), + ("cline@2.0.0".to_string(), None) + ); + assert!(npm_install_attempts("cline@3.0.9", Some("nightly"), true).is_err()); + } + + // Only the exact (trimmed) sentinel opts into the latest channel; absence + // and every other value stay on the pin, matching the frontend reader. + #[test] + fn adapter_channel_reads_only_the_exact_latest_sentinel() { + let env = |value: Option<&str>| { + let mut map = BTreeMap::new(); + map.insert("XAI_API_KEY".to_string(), "abc".to_string()); + if let Some(value) = value { + map.insert(ADAPTER_CHANNEL_ENV.to_string(), value.to_string()); + } + map + }; + assert!(!adapter_channel_is_latest(&env(None))); + assert!(adapter_channel_is_latest(&env(Some("latest")))); + assert!(adapter_channel_is_latest(&env(Some(" latest ")))); + assert!(!adapter_channel_is_latest(&env(Some("pinned")))); + assert!(!adapter_channel_is_latest(&env(Some("Latest")))); + assert!(!adapter_channel_is_latest(&env(Some("")))); + } + #[test] fn apply_custom_version_to_url_substitutes_all_occurrences() { // Codex URL embeds the version twice (path tag + asset filename). diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 5bf0706ad0..4532f2ec38 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest" import { + adapterChannelFromEnv, + adapterChannelFromEnvText, applyClaudeProviderToConfigText, buildCodexSandboxConfig, codexSandboxBaselineOf, @@ -23,6 +25,7 @@ import { patchImportantConfigText, codexSandboxSeedsAcpPreset, rebaseDeepSeekDraft, + setAdapterChannel, setClaudeEnvFlagInConfigText, setHostToolsAgentMode, showsCodexReadOnlyAcpWarning, @@ -826,6 +829,77 @@ describe("buildVersionCheck", () => { expect(check?.status).toBe("fail") expect(check?.fixes).toHaveLength(0) }) + + // The opt-in latest channel keeps the Upgrade action available in the pass + // state: an installed latest-channel agent normally sits AT or AHEAD of the + // pin, so the compare-to-pin flow would never offer an upgrade again — and + // codeg cannot know whether npm has something newer, because nothing polls + // in the background. + it("keeps Upgrade available for an installed latest-channel npx agent", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "gemini" as AgentType, + distribution_type: "npx", + registry_version: "0.57.0", + installed_version: "0.60.0", + env: { CODEG_ADAPTER_CHANNEL: "latest" }, + }) + ) + expect(check?.status).toBe("pass") + expect(check?.message).toContain("Latest channel") + expect(check?.fixes.some((fix) => fix.kind === "upgrade_npx")).toBe(true) + expect(check?.fixes.some((fix) => fix.kind === "uninstall_npx")).toBe(true) + }) + + // The pinned default's pass state is byte-for-byte what it was before the + // channel existed. + it("leaves the pinned default's pass state unchanged", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "gemini" as AgentType, + distribution_type: "npx", + registry_version: "0.57.0", + installed_version: "0.57.0", + }) + ) + expect(check?.status).toBe("pass") + expect(check?.message).toContain("Already latest") + expect(check?.fixes.some((fix) => fix.kind === "upgrade_npx")).toBe(false) + }) + + // A latest-channel agent below the pin still warns: the upgrade it offers + // resolves the `latest` dist-tag, which is at least the pin. + it("still warns when a latest-channel agent sits below the pin", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "gemini" as AgentType, + distribution_type: "npx", + registry_version: "0.57.0", + installed_version: "0.50.0", + env: { CODEG_ADAPTER_CHANNEL: "latest" }, + }) + ) + expect(check?.status).toBe("warn") + expect(check?.message).toContain("Upgrade available") + }) + + // The channel is an npx concept (an npm dist-tag), so the same env key on a + // binary agent must not rewrite its version card. + it("ignores the channel key on a non-npx agent", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "open_code" as AgentType, + distribution_type: "binary", + registry_version: "1.0.0", + installed_version: "1.0.0", + env: { CODEG_ADAPTER_CHANNEL: "latest" }, + }) + ) + expect(check?.message).toContain("Already latest") + expect(check?.fixes.some((fix) => fix.kind === "upgrade_binary")).toBe( + false + ) + }) }) describe("getAgentChecks uv gating", () => { @@ -1761,6 +1835,50 @@ describe("host-tools toggle — hand the fs/terminal channels back to the agent" }) }) +describe("adapter-channel control — opt into the latest adapter release", () => { + const KEY = "CODEG_ADAPTER_CHANNEL" + + it("defaults to pinned for an agent that has never touched the control", () => { + expect(adapterChannelFromEnvText("")).toBe("pinned") + expect(adapterChannelFromEnvText("XAI_API_KEY=abc")).toBe("pinned") + expect(adapterChannelFromEnv({})).toBe("pinned") + }) + + it("round-trips latest and back to pinned", () => { + const latest = setAdapterChannel("XAI_API_KEY=abc", "latest") + expect(latest).toContain(`${KEY}=latest`) + expect(adapterChannelFromEnvText(latest)).toBe("latest") + + // Pinned DELETES the key: unlike the host-tools knob there is no + // process-env second layer that could make "absent" mean something else, + // so absent is unambiguously the default on both sides, and the raw + // editor stays free of a key that only restates it. + const pinned = setAdapterChannel(latest, "pinned") + expect(pinned).not.toContain(KEY) + expect(adapterChannelFromEnvText(pinned)).toBe("pinned") + expect(pinned).toContain("XAI_API_KEY=abc") + }) + + it("reads only the exact sentinel, matching the Rust reader", () => { + // `adapter_channel_is_latest` (commands/acp.rs) treats exactly the trimmed + // `latest` as the opt-in; everything else stays on the reviewed pin. + expect(adapterChannelFromEnvText(`${KEY}=latest`)).toBe("latest") + expect(adapterChannelFromEnvText(`${KEY} = latest `)).toBe("latest") + expect(adapterChannelFromEnvText(`${KEY}=Latest`)).toBe("pinned") + expect(adapterChannelFromEnvText(`${KEY}=pinned`)).toBe("pinned") + expect(adapterChannelFromEnvText(`${KEY}=`)).toBe("pinned") + expect(adapterChannelFromEnv({ [KEY]: "latest" })).toBe("latest") + expect(adapterChannelFromEnv({ [KEY]: "nightly" })).toBe("pinned") + }) + + it("does not double up when selected twice", () => { + const once = setAdapterChannel("", "latest") + const twice = setAdapterChannel(once, "latest") + expect(twice).toBe(once) + expect(twice.match(new RegExp(KEY, "g"))).toHaveLength(1) + }) +}) + describe("rebaseDeepSeekDraft", () => { // Only the fields this helper reads or writes; the rest of AgentDraft is // spread through untouched, which the identity assertion below pins. diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 0bf58a8f1d..1586960d2c 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -548,6 +548,56 @@ export function setHostToolsAgentMode( }) } +/** + * Per-agent `env_json` key that opts an npx agent into installing the + * package's `latest` npm dist-tag instead of the maintainer-reviewed pin. + * Same storage as pi's runtime override and the host-tools knob above. The + * backend reads it at install/upgrade time only: a launch always runs whatever + * is installed, nothing polls npm in the background, and a failed latest + * install falls back to the pinned version with a note in the install log. + */ +const ADAPTER_CHANNEL_ENV = "CODEG_ADAPTER_CHANNEL" +const ADAPTER_CHANNEL_LATEST = "latest" + +export type AdapterChannel = "pinned" | "latest" + +/** + * Which adapter channel an env draft selects. Anything other than the exact + * (trimmed) `latest` sentinel reads as pinned, matching the Rust reader + * (`adapter_channel_is_latest`), which treats the pin as the only default. + */ +export function adapterChannelFromEnvText(envText: string): AdapterChannel { + return parseEnvText(envText)[ADAPTER_CHANNEL_ENV]?.trim() === + ADAPTER_CHANNEL_LATEST + ? "latest" + : "pinned" +} + +/** [`adapterChannelFromEnvText`] over the saved env map the backend reports. */ +export function adapterChannelFromEnv( + env: Record +): AdapterChannel { + return env[ADAPTER_CHANNEL_ENV]?.trim() === ADAPTER_CHANNEL_LATEST + ? "latest" + : "pinned" +} + +/** + * Select the adapter channel in an env draft. Pinned DELETES the key: unlike + * the host-tools knob there is no process-env second layer that could make + * "absent" mean something else, so absent is unambiguously the pinned default + * on both sides, and the raw editor stays free of a key that only restates it. + */ +export function setAdapterChannel( + envText: string, + channel: AdapterChannel +): string { + return patchEnvText(envText, { + [ADAPTER_CHANNEL_ENV]: + channel === "latest" ? ADAPTER_CHANNEL_LATEST : undefined, + }) +} + interface ImportantEnvKeys { apiBaseUrl: string[] apiKey: string[] @@ -3930,6 +3980,12 @@ export function buildVersionCheck( const withCustomInstall = (fixes: UiFixAction[]): UiFixAction[] => supportsCustomInstall ? [...fixes, customInstallFix] : fixes + // The opt-in "Adapter version: Latest" channel (npx agents only) — install + // and upgrade actions resolve the `latest` dist-tag instead of the pin. + const latestChannel = + agent.distribution_type === "npx" && + adapterChannelFromEnv(agent.env) === "latest" + if (!agent.installed_version) { return { check_id: "version_status", @@ -4049,6 +4105,37 @@ export function buildVersionCheck( } } + // A latest-channel agent's installed version normally sits AT or AHEAD of + // the pin, so the compare-to-pin branch above never offers an upgrade again + // — and codeg cannot know whether npm has something newer, because nothing + // polls in the background (by design). Keep the Upgrade action available: + // it resolves the `latest` dist-tag on demand, and "Already latest" would + // claim a comparison that was never made. + if (latestChannel) { + return { + check_id: "version_status", + label: acpText("version.statusLabel", "Version Status"), + status: "pass", + message: acpText( + "version.latestChannel", + "{versionText}. Latest channel is on; Upgrade installs the newest release.", + { versionText } + ), + fixes: withCustomInstall([ + { + label: acpText("actions.upgrade", "Upgrade"), + kind: upgradeAction, + payload: agent.agent_type, + }, + { + label: acpText("actions.uninstall", "Uninstall"), + kind: uninstallAction, + payload: agent.agent_type, + }, + ]), + } + } + return { check_id: "version_status", label: acpText("version.statusLabel", "Version Status"), @@ -7916,6 +8003,58 @@ export function AcpAgentSettings() { aria-label={t("hostTools.label")} /> + {/* + Same contract as the host-tools switch above: backed by the + `envText` draft, persisted by the one Save button. Npx + agents only — a binary or uvx install has no npm dist-tag + to track. + */} + {selectedAgent.distribution_type === "npx" && ( +
+
+ +

+ {t("adapterChannel.description")} +

+ {adapterChannelFromEnvText(selectedDraft.envText) === + "latest" && ( +

+ {t("adapterChannel.latestWarning")} +

+ )} +
+ +
+ )}