diff --git a/Cargo.lock b/Cargo.lock index b5b2605a4..32152e41a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -899,6 +899,7 @@ dependencies = [ "nostr", "rand 0.10.1", "reqwest 0.13.4", + "rustls", "serde", "serde_json", "sha2 0.11.0", diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index b553adaab..3743314a2 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -1863,6 +1863,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. @@ -1874,20 +1932,21 @@ 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) { let config_id = match config_opt.get("configId").and_then(|v| v.as_str()) { Some(id) => id, 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, + }); } } } @@ -1895,12 +1954,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 }); } } } @@ -1920,28 +1978,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 ───────────────────────────────────────────────── @@ -2474,6 +2529,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 862732f47..bbbe9094e 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2946,6 +2946,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, @@ -3043,6 +3068,23 @@ fn handle_prompt_result( } else { hard_timeout_fate_suffix = Some(" — requeued for retry (recently active)"); } + } 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 a12260b52..d3ad14697 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -76,6 +76,11 @@ dirs = "6" # WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay) buzz-ws-client = { path = "../buzz-ws-client" } +# 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 rand = { workspace = true } diff --git a/crates/buzz-cli/src/main.rs b/crates/buzz-cli/src/main.rs index ff337776b..36888253c 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 597b1b932..b93d51065 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 60c77bb04..c1579fdb5 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 }; }