From 056f948bd7bb5ae99fbf3f65f5836cc36faacbe6 Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 15:48:09 +0300 Subject: [PATCH 1/3] fix(acp): surface Claude usage-credit failures immediately Dead-letter non-retryable application errors with a channel notice, resolve short model aliases like sonnet, and map credit errors in the Agents UI. Signed-off-by: Taksh Signed-off-by: Taksh --- crates/buzz-acp/src/acp.rs | 147 ++++++++++++++---- crates/buzz-acp/src/lib.rs | 42 +++++ .../lib/friendlyAgentLastError.test.mjs | 12 ++ .../agents/lib/friendlyAgentLastError.ts | 9 ++ 4 files changed, 183 insertions(+), 27 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 35145805196..12868a6c5f2 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -2089,6 +2089,64 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option MatchKind { + if candidate == desired { + return MatchKind::Exact; + } + if candidate.eq_ignore_ascii_case(desired) { + return MatchKind::CaseInsensitive; + } + let c = candidate.to_ascii_lowercase(); + let d = desired.to_ascii_lowercase(); + if d.len() >= 3 { + let padded = format!("-{c}-"); + if padded.contains(&format!("-{d}-")) { + return MatchKind::Alias; + } + } + MatchKind::None +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MatchKind { + Exact, + CaseInsensitive, + Alias, + None, +} + +fn pick_matching_model_id<'a>( + candidates: impl IntoIterator, + desired: &str, +) -> Option { + let mut exact = None; + let mut case_insensitive = None; + let mut aliases = Vec::new(); + for candidate in candidates { + match model_id_matches(candidate, desired) { + MatchKind::Exact => exact = Some(candidate.to_string()), + MatchKind::CaseInsensitive if case_insensitive.is_none() => { + case_insensitive = Some(candidate.to_string()); + } + MatchKind::Alias => aliases.push(candidate.to_string()), + _ => {} + } + } + exact.or(case_insensitive).or_else(|| { + if aliases.len() == 1 { + aliases.pop() + } else { + None + } + }) +} + /// Match a desired model ID against a fresh `session/new` response. /// /// Returns the correct ACP method to call, or `None` if no match. @@ -2100,7 +2158,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 / ci / alias). 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 @@ -2114,13 +2172,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, + }); } } } @@ -2128,12 +2187,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 }); } } } @@ -2153,28 +2211,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 ───────────────────────────────────────────────── @@ -2750,6 +2805,44 @@ mod tests { assert!(super::resolve_model_switch_method(&result, "nonexistent-model").is_none()); } + #[test] + fn resolve_matches_short_alias_to_unique_catalog_id() { + // BUZZ_ACP_MODEL=sonnet should resolve against full Anthropic ids (#2265). + let result = serde_json::json!({ + "configOptions": [{ + "configId": "model", + "category": "model", + "options": [ + { "value": "claude-sonnet-4-20250514", "displayName": "Sonnet 4" }, + { "value": "claude-opus-4-20250514", "displayName": "Opus 4" } + ] + }] + }); + let method = super::resolve_model_switch_method(&result, "sonnet"); + assert_eq!( + method, + Some(super::ModelSwitchMethod::ConfigOption { + config_id: "model".to_string(), + option_value: "claude-sonnet-4-20250514".to_string(), + }) + ); + } + + #[test] + fn resolve_refuses_ambiguous_alias() { + let result = serde_json::json!({ + "configOptions": [{ + "configId": "model", + "category": "model", + "options": [ + { "value": "claude-sonnet-4-20250514" }, + { "value": "claude-sonnet-3-7-20250219" } + ] + }] + }); + assert!(super::resolve_model_switch_method(&result, "sonnet").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 d63f720c651..e5711fdda30 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3051,6 +3051,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, @@ -3163,6 +3188,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/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 }; } From b177393b53ca757877332d9be4f9a275c28edce8 Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 23 Jul 2026 15:48:09 +0300 Subject: [PATCH 2/3] fix(cli): install rustls CryptoProvider before wss publish Workspace builds enable both ring and aws-lc-rs; without an entrypoint install, agents draft-create panics on secure relays. Signed-off-by: Taksh Signed-off-by: Taksh --- crates/buzz-cli/Cargo.toml | 4 +++- crates/buzz-cli/src/main.rs | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd4..8841d4f0161 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); } From 4abaa8ef0e21a57c343c42893e22dc0133dde22e Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 12 Sep 2026 12:17:34 +0530 Subject: [PATCH 3/3] fix(acp): constrain moving model family aliases Signed-off-by: Taksh --- crates/buzz-acp/src/acp.rs | 169 ++++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 67 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index cc25457a44a..132770d743a 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -2182,35 +2182,35 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option MatchKind { if candidate == desired { return MatchKind::Exact; } - if candidate.eq_ignore_ascii_case(desired) { - return MatchKind::CaseInsensitive; - } - let c = candidate.to_ascii_lowercase(); - let d = desired.to_ascii_lowercase(); - if d.len() >= 3 { - let padded = format!("-{c}-"); - if padded.contains(&format!("-{d}-")) { - return MatchKind::Alias; + 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 } } - MatchKind::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, - CaseInsensitive, Alias, None, } @@ -2219,27 +2219,20 @@ fn pick_matching_model_id<'a>( candidates: impl IntoIterator, desired: &str, ) -> Option { - let mut exact = None; - let mut case_insensitive = None; let mut aliases = Vec::new(); for candidate in candidates { match model_id_matches(candidate, desired) { - MatchKind::Exact => exact = Some(candidate.to_string()), - MatchKind::CaseInsensitive if case_insensitive.is_none() => { - case_insensitive = Some(candidate.to_string()); - } + MatchKind::Exact => return Some(candidate.to_string()), MatchKind::Alias => aliases.push(candidate.to_string()), - _ => {} + MatchKind::None => {} } } - exact.or(case_insensitive).or_else(|| { - if aliases.len() == 1 { - aliases.pop() - } else { - 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. @@ -2261,7 +2254,6 @@ pub fn extract_thought_level_config_id(result: &serde_json::Value) -> Option Option { // 1. Search stable configOptions for a "model"-category entry whose - // options contain a value matching desired_model (exact / ci / alias). + // 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 @@ -2971,41 +2963,84 @@ mod tests { } #[test] - fn resolve_matches_short_alias_to_unique_catalog_id() { - // BUZZ_ACP_MODEL=sonnet should resolve against full Anthropic ids (#2265). - let result = serde_json::json!({ - "configOptions": [{ - "configId": "model", - "category": "model", - "options": [ - { "value": "claude-sonnet-4-20250514", "displayName": "Sonnet 4" }, - { "value": "claude-opus-4-20250514", "displayName": "Opus 4" } - ] - }] - }); - let method = super::resolve_model_switch_method(&result, "sonnet"); - assert_eq!( - method, - Some(super::ModelSwitchMethod::ConfigOption { - config_id: "model".to_string(), - option_value: "claude-sonnet-4-20250514".to_string(), - }) - ); + 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 resolve_refuses_ambiguous_alias() { - let result = serde_json::json!({ - "configOptions": [{ - "configId": "model", - "category": "model", - "options": [ - { "value": "claude-sonnet-4-20250514" }, - { "value": "claude-sonnet-3-7-20250219" } - ] - }] - }); - assert!(super::resolve_model_switch_method(&result, "sonnet").is_none()); + 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]