diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 0d87bca028c..132770d743a 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -2182,6 +2182,58 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option MatchKind { + if candidate == desired { + return MatchKind::Exact; + } + fn qualified_family(id: &str) -> Option<&str> { + let (family, qualifier) = id.split_once('[')?; + let qualifier = qualifier.strip_suffix(']')?; + if matches!(family, "opus" | "fable" | "sonnet" | "haiku") + && !qualifier.is_empty() + && !qualifier.contains(['[', ']']) + { + Some(family) + } else { + None + } + } + if qualified_family(candidate) == Some(desired) || qualified_family(desired) == Some(candidate) + { + MatchKind::Alias + } else { + MatchKind::None + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MatchKind { + Exact, + Alias, + None, +} + +fn pick_matching_model_id<'a>( + candidates: impl IntoIterator, + desired: &str, +) -> Option { + let mut aliases = Vec::new(); + for candidate in candidates { + match model_id_matches(candidate, desired) { + MatchKind::Exact => return Some(candidate.to_string()), + MatchKind::Alias => aliases.push(candidate.to_string()), + MatchKind::None => {} + } + } + if aliases.len() == 1 { + aliases.pop() + } else { + None + } +} + /// Extract the `configId` for the `thought_level` category option from a /// `session/new` result, if the adapter advertised one. /// @@ -2215,7 +2267,7 @@ pub fn resolve_model_switch_method( desired_model: &str, ) -> Option { // 1. Search stable configOptions for a "model"-category entry whose - // options contain a value matching desired_model. + // options contain a value matching desired_model (exact or moving-family compatibility). for config_opt in extract_model_config_options(session_new_result) { // Adapters disagree on the key: the ACP spec says `configId`, but // claude-agent-acp emits `id`. Accept both; the set request always @@ -2229,13 +2281,14 @@ pub fn resolve_model_switch_method( None => continue, }; if let Some(options) = config_opt.get("options").and_then(|v| v.as_array()) { - for opt in options { - if opt.get("value").and_then(|v| v.as_str()) == Some(desired_model) { - return Some(ModelSwitchMethod::ConfigOption { - config_id: config_id.to_string(), - option_value: desired_model.to_string(), - }); - } + let values = options + .iter() + .filter_map(|opt| opt.get("value").and_then(|v| v.as_str())); + if let Some(option_value) = pick_matching_model_id(values, desired_model) { + return Some(ModelSwitchMethod::ConfigOption { + config_id: config_id.to_string(), + option_value, + }); } } } @@ -2243,12 +2296,11 @@ pub fn resolve_model_switch_method( // 2. Search unstable availableModels for a matching modelId. if let Some(models) = extract_model_state(session_new_result) { if let Some(available) = models.get("availableModels").and_then(|v| v.as_array()) { - for model in available { - if model.get("modelId").and_then(|v| v.as_str()) == Some(desired_model) { - return Some(ModelSwitchMethod::SetModel { - model_id: desired_model.to_string(), - }); - } + let ids = available + .iter() + .filter_map(|model| model.get("modelId").and_then(|v| v.as_str())); + if let Some(model_id) = pick_matching_model_id(ids, desired_model) { + return Some(ModelSwitchMethod::SetModel { model_id }); } } } @@ -2268,28 +2320,25 @@ pub fn model_in_catalog( available_models: Option<&serde_json::Value>, desired_model: &str, ) -> bool { - let in_config_options = config_options.iter().any(|config_opt| { + let config_values = config_options.iter().flat_map(|config_opt| { config_opt .get("options") .and_then(|v| v.as_array()) - .is_some_and(|options| { - options - .iter() - .any(|opt| opt.get("value").and_then(|v| v.as_str()) == Some(desired_model)) - }) + .into_iter() + .flatten() + .filter_map(|opt| opt.get("value").and_then(|v| v.as_str())) }); - if in_config_options { + if pick_matching_model_id(config_values, desired_model).is_some() { return true; } - available_models + let ids = available_models .and_then(|models| models.get("availableModels")) .and_then(|v| v.as_array()) - .is_some_and(|available| { - available - .iter() - .any(|model| model.get("modelId").and_then(|v| v.as_str()) == Some(desired_model)) - }) + .into_iter() + .flatten() + .filter_map(|model| model.get("modelId").and_then(|v| v.as_str())); + pick_matching_model_id(ids, desired_model).is_some() } // ─── Drop: kill child process ───────────────────────────────────────────────── @@ -2913,6 +2962,87 @@ mod tests { assert!(super::resolve_model_switch_method(&result, "nonexistent-model").is_none()); } + #[test] + fn moving_family_matches_preserve_advertised_values_in_both_catalogs() { + for family in ["opus", "fable", "sonnet", "haiku"] { + let qualified = format!("{family}[1m]"); + for (candidate, desired) in [(family, qualified.as_str()), (qualified.as_str(), family)] + { + let result = serde_json::json!({ + "configOptions": [{"configId": "model", "category": "model", + "options": [{"value": candidate}]}] + }); + assert_eq!( + super::resolve_model_switch_method(&result, desired), + Some(super::ModelSwitchMethod::ConfigOption { + config_id: "model".to_string(), + option_value: candidate.to_string(), + }) + ); + assert!(super::model_in_catalog( + &super::extract_model_config_options(&result), + None, + desired + )); + + let result = serde_json::json!({ + "models": {"availableModels": [{"modelId": candidate}]} + }); + assert_eq!( + super::resolve_model_switch_method(&result, desired), + Some(super::ModelSwitchMethod::SetModel { + model_id: candidate.to_string() + }) + ); + assert!(super::model_in_catalog(&[], result.get("models"), desired)); + } + } + } + + #[test] + fn model_aliases_reject_pinned_case_and_qualified_variant_changes() { + for (candidate, desired) in [ + ("claude-opus-4-20250514", "opus"), + ("opus", "claude-opus-4-20250514"), + ("composer-2.5[fast=true]", "composer-2.5[fast=false]"), + ("composer-2.5[fast=true]", "composer-2.5"), + ("opus[1m]", "opus[fast=true]"), + ("Opus", "opus"), + ("OPUS[1m]", "opus"), + ("opus[1m][fast=true]", "opus"), + ("opus[]", "opus"), + ] { + let result = serde_json::json!({ + "configOptions": [{"configId": "model", "category": "model", + "options": [{"value": candidate}]}], + "models": {"availableModels": [{"modelId": candidate}]} + }); + assert!( + super::resolve_model_switch_method(&result, desired).is_none(), + "{candidate} must not match {desired}" + ); + assert!(!super::model_in_catalog( + &super::extract_model_config_options(&result), + result.get("models"), + desired + )); + assert!(super::resolve_model_switch_method(&result, candidate).is_some()); + } + } + + #[test] + fn model_aliases_prefer_exact_and_reject_ambiguous_qualifiers() { + assert_eq!( + super::pick_matching_model_id(["opus[1m]", "opus"], "opus"), + Some("opus".to_string()) + ); + assert_eq!( + super::pick_matching_model_id(["opus", "opus[1m]"], "opus[1m]"), + Some("opus[1m]".to_string()) + ); + assert!(super::pick_matching_model_id(["opus[1m]", "opus[fast=true]"], "opus").is_none()); + } + #[test] fn resolve_returns_none_when_no_model_info() { let result = serde_json::json!({ "sessionId": "sess-1" }); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index af504a11768..f6f2a8496f6 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4449,6 +4449,31 @@ fn spawn_failure_notice( } } +/// Application errors that will not succeed on requeue (credits, auth). +/// +/// Matched on the Display text so we cover both ACP-wrapped +/// (`Agent reported error (code -32603): …`) and bare harness messages. +fn is_non_retryable_application_error(err: &acp::AcpError) -> bool { + let lower = err.to_string().to_ascii_lowercase(); + lower.contains("usage credits") + || lower.contains("/usage-credits") + || lower.contains("invalid api key") + || lower.contains("authentication_error") + || lower.contains("incorrect api key") + || (lower.contains("unauthorized") && lower.contains("api")) +} + +fn non_retryable_failure_notice(err: &acp::AcpError) -> String { + let text = err.to_string(); + let lower = text.to_ascii_lowercase(); + if lower.contains("usage credits") || lower.contains("/usage-credits") { + return "⚠️ I couldn't complete that turn — the configured Claude model requires usage credits. Switch to a plan-included model (e.g. `sonnet`) in agent settings, or run `/usage-credits` in Claude Code.".to_string(); + } + format!( + "⚠️ I couldn't complete that turn ({text}). Please fix the configuration and re-send if it's still needed." + ) +} + #[allow(clippy::too_many_arguments)] fn handle_prompt_result( pool: &mut AgentPool, @@ -4583,6 +4608,23 @@ fn handle_prompt_result( and then re-send." .to_string(); spawn_failure_notice(rest_client, &batch, content); + } else if let PromptOutcome::Error(ref err) = result.outcome { + if is_non_retryable_application_error(err) { + // Credits / auth failures will never succeed on retry — tell + // the channel immediately instead of silent backoff (#2265). + tracing::error!( + channel_id = %batch.channel_id, + error = %err, + "dead-lettering batch after non-retryable application error" + ); + let content = non_retryable_failure_notice(err); + spawn_failure_notice(rest_client, &batch, content); + } else if let Some(dead) = queue.requeue(batch) { + let content = format!( + "⚠️ I couldn't process the last request after multiple retries ({err}). Please re-send if it's still needed." + ); + spawn_failure_notice(rest_client, &dead, content); + } } else if let Some(dead) = queue.requeue(batch) { let reason = match &result.outcome { PromptOutcome::Timeout(TimeoutKind::Idle) => "the turn timed out".to_string(), diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 59d1bb2cee6..28f8e01a264 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -80,7 +80,9 @@ buzz-ws-client = { path = "../buzz-ws-client" } # CryptoProvider at startup. Without this the standalone `buzz` binary panics when # a multi-package release build (buzz-acp + buzz-dev-mcp + buzz-cli in one cargo # invocation) unifies both ring and aws-lc-rs features, leaving rustls unable to -# auto-select a provider. See crates/buzz-acp/Cargo.toml for the same dependency. +# auto-select a provider. Workspace builds pull both rustls crypto providers (ring +# via relay crates, aws-lc-rs via reqwest). Install ring at CLI entry so wss:// +# publish paths (agents draft-create, …) don't panic — see #2308 / #2329 / #2457. rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } # Random number generation — full jitter for exponential backoff in with_retry diff --git a/crates/buzz-cli/src/main.rs b/crates/buzz-cli/src/main.rs index ff337776b31..36888253c4f 100644 --- a/crates/buzz-cli/src/main.rs +++ b/crates/buzz-cli/src/main.rs @@ -1,4 +1,10 @@ #[tokio::main] async fn main() { + // Workspace feature unification compiles both rustls providers (ring + + // aws-lc-rs). Without an explicit install, the first wss:// publish panics + // inside tokio-tungstenite (#2308 / #2329 / #2457). Idempotent if another + // path already installed a provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + std::process::exit(buzz_cli::run_from_args(std::env::args()).await); } diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs index 597b1b9323e..b93d510657c 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs @@ -7,6 +7,7 @@ import { CLI_ACP_INTERNAL_ERROR_COPY, MODEL_NOT_FOUND_COPY, RELAY_MESH_DENIED_COPY, + USAGE_CREDITS_COPY, } from "./friendlyAgentLastError.ts"; test("null lastError → null", () => { @@ -127,6 +128,17 @@ test("unknown code falls through to generic", () => { }); }); +test("usage-credits internal error → denied credits copy", () => { + const result = friendlyAgentLastError( + "Agent reported error (code -32603): Internal error: Fable 5 requires usage credits. Run /usage-credits to continue.", + -32603, + ); + assert.deepEqual(result, { + severity: "denied", + copy: USAGE_CREDITS_COPY, + }); +}); + test("friendlyTurnErrorCopy: numeric code -32002 → model-not-found copy", () => { assert.equal( friendlyTurnErrorCopy("raw error", -32002), diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts index 60c77bb04cc..c1579fdb5ed 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts @@ -42,6 +42,9 @@ export const RELAY_MESH_DENIED_COPY = export const MODEL_NOT_FOUND_COPY = "The configured model is not available — open agent settings and select a different one from the dropdown."; +export const USAGE_CREDITS_COPY = + "This Claude model requires usage credits — switch to a plan-included model (e.g. sonnet) or run /usage-credits in Claude Code."; + export const CLI_ACP_INTERNAL_ERROR_COPY = "The agent's harness reported an internal error. For Codex agents this can mean the configured model isn't supported by your installed codex-acp — check the model in `~/.codex/config.toml` or upgrade the adapter (`brew upgrade codex-acp`)."; @@ -96,6 +99,9 @@ export function friendlyAgentLastError( if (remainder === BARE_INTERNAL_ERROR) { return { severity: "generic", copy: CLI_ACP_INTERNAL_ERROR_COPY }; } + if (/usage credits/i.test(remainder)) { + return { severity: "denied", copy: USAGE_CREDITS_COPY }; + } return { severity: "generic", copy: remainder }; } } @@ -112,6 +118,9 @@ export function friendlyAgentLastError( ) { return { severity: "denied", copy: RELAY_MESH_DENIED_COPY }; } + if (/usage credits/i.test(trimmed)) { + return { severity: "denied", copy: USAGE_CREDITS_COPY }; + } return { severity: "generic", copy: trimmed }; }