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
184 changes: 157 additions & 27 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2182,6 +2182,58 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option<serde_json::Val
result.get("models").cloned()
}

/// Match exact IDs or a bare moving family and its single bracket qualifier.
/// Full IDs and two differently qualified IDs must remain exact.
fn model_id_matches(candidate: &str, desired: &str) -> 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<Item = &'a str>,
desired: &str,
) -> Option<String> {
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.
///
Expand Down Expand Up @@ -2215,7 +2267,7 @@ pub fn resolve_model_switch_method(
desired_model: &str,
) -> Option<ModelSwitchMethod> {
// 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
Expand All @@ -2229,26 +2281,26 @@ 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,
});
}
}
}

// 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 });
}
}
}
Expand All @@ -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 ─────────────────────────────────────────────────
Expand Down Expand Up @@ -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" });
Expand Down
42 changes: 42 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 3 additions & 1 deletion crates/buzz-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/buzz-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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);
}
12 changes: 12 additions & 0 deletions desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/features/agents/lib/friendlyAgentLastError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).";

Expand Down Expand Up @@ -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 };
}
}
Expand All @@ -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 };
}
Expand Down