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
195 changes: 184 additions & 11 deletions src-tauri/src/commands/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>) -> 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<String>), 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.
Expand Down Expand Up @@ -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 `<name>@<version>` 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(),
Expand All @@ -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 `<name>@latest` for a latest-channel agent); a
// custom version installs `<name>@<version>` 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
118 changes: 118 additions & 0 deletions src/components/settings/acp-agent-settings.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest"

import {
adapterChannelFromEnv,
adapterChannelFromEnvText,
applyClaudeProviderToConfigText,
buildCodexSandboxConfig,
codexSandboxBaselineOf,
Expand All @@ -23,6 +25,7 @@ import {
patchImportantConfigText,
codexSandboxSeedsAcpPreset,
rebaseDeepSeekDraft,
setAdapterChannel,
setClaudeEnvFlagInConfigText,
setHostToolsAgentMode,
showsCodexReadOnlyAcpWarning,
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading