From 0e89dd7b4a498043ab6206dd3489493bea6c5ac9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 8 Jul 2026 15:38:46 -0400 Subject: [PATCH 1/5] fix(agents): surface provider errors structurally instead of raw dumps When a provider returns HTTP 404 (e.g. Databricks model not accessible), the error code was lost at the ACP process boundary and the desktop showed raw JSON to the user. Thread the JSON-RPC error code from buzz-agent through buzz-acp's observer payload to the desktop, so the UI dispatches on numeric codes (-32001 auth, -32002 model-not-found) instead of fragile string matching. --- crates/buzz-acp/src/acp.rs | 14 ++++-- crates/buzz-acp/src/lib.rs | 28 ++++++++--- crates/buzz-acp/src/pool.rs | 2 +- crates/buzz-agent/src/llm.rs | 7 +++ crates/buzz-agent/src/types.rs | 3 ++ .../src-tauri/src/commands/agent_settings.rs | 2 +- desktop/src-tauri/src/commands/agents.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 18 +++++-- .../src-tauri/src/managed_agents/storage.rs | 23 +++++++-- desktop/src-tauri/src/managed_agents/types.rs | 4 ++ .../lib/friendlyAgentLastError.test.mjs | 50 +++++++++++++++++++ .../agents/lib/friendlyAgentLastError.ts | 14 ++++++ .../features/agents/ui/ManagedAgentRow.tsx | 2 +- .../agents/ui/UnifiedAgentsSection.tsx | 2 +- .../agents/ui/agentSessionTranscript.ts | 7 ++- desktop/src/shared/api/tauri.ts | 2 + desktop/src/shared/api/types.ts | 1 + 17 files changed, 156 insertions(+), 24 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index ea11fce12f..2a3999dd46 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -101,8 +101,8 @@ pub enum AcpError { #[error("Protocol error: {0}")] Protocol(String), - #[error("Agent reported error: {0}")] - AgentError(String), + #[error("Agent reported error: {message}")] + AgentError { code: i64, message: String }, } /// ACP client that owns an agent subprocess and communicates over its stdio. @@ -863,7 +863,10 @@ impl AcpClient { if let Some(id) = msg.get("id") { if *id == serde_json::json!(expected_id) && msg.get("method").is_none() { if let Some(error) = msg.get("error") { - return Err(AcpError::AgentError(error.to_string())); + let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-32000); + let message = error.get("message").and_then(|m| m.as_str()) + .unwrap_or("unknown error").to_string(); + return Err(AcpError::AgentError { code, message }); } return Ok(msg["result"].clone()); } @@ -1185,7 +1188,10 @@ impl AcpClient { let _ = ack_tx .send(crate::pool::SteerAck::PromptCompletedNeutral); } - return Err(AcpError::AgentError(error.to_string())); + let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-32000); + let message = error.get("message").and_then(|m| m.as_str()) + .unwrap_or("unknown error").to_string(); + return Err(AcpError::AgentError { code, message }); } if let Some((_, ack_tx)) = pending_steer.take() { let _ = diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b6ecbd7aa8..c70c66c288 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2782,16 +2782,20 @@ fn handle_prompt_result( PromptSource::Channel(ch) => Some(*ch), PromptSource::Heartbeat => None, }; - let emit_turn_error = |error_msg: &str| { + let emit_turn_error = |error_msg: &str, error_code: Option| { if let Some(ref observer) = observer { + let mut payload = serde_json::json!({ + "outcome": outcome_label, + "error": error_msg, + }); + if let Some(code) = error_code { + payload["code"] = serde_json::json!(code); + } observer.emit( "turn_error", Some(agent_index), &observer::context_for(channel_id, None, None), - serde_json::json!({ - "outcome": outcome_label, - "error": error_msg, - }), + payload, ); } }; @@ -2819,7 +2823,7 @@ fn handle_prompt_result( "exited" => "Agent process exited unexpectedly", _ => "Agent session timed out due to inactivity", }; - emit_turn_error(death_message); + emit_turn_error(death_message, None); let index = result.agent.index; let slot_history = &mut crash_history[index]; @@ -2880,7 +2884,11 @@ fn handle_prompt_result( error = %e, "transport/protocol error — respawning agent" ); - emit_turn_error(&e.to_string()); + let error_code = match &e { + acp::AcpError::AgentError { code, .. } => Some(*code), + _ => None, + }; + emit_turn_error(&e.to_string(), error_code); let index = result.agent.index; let slot_history = &mut crash_history[index]; @@ -2906,7 +2914,11 @@ fn handle_prompt_result( error = %e, "agent_returned (application error — pipe intact)" ); - emit_turn_error(&e.to_string()); + let error_code = match &e { + acp::AcpError::AgentError { code, .. } => Some(*code), + _ => None, + }; + emit_turn_error(&e.to_string(), error_code); pool.return_agent(result.agent); } } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 6a834f77cd..5555fd6468 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1884,7 +1884,7 @@ pub async fn run_prompt_task( // AgentError means the agent caught a problem before mutating // session state (e.g. bad LLM response). The session is healthy — // don't invalidate it. Other errors may have corrupted state. - if !matches!(e, AcpError::AgentError(_)) { + if !matches!(e, AcpError::AgentError { .. }) { agent.state.invalidate(&source); } let usage = agent.acp.take_turn_usage(); diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 07c2071aa2..6f524086ac 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -149,6 +149,7 @@ impl Llm { // is centralized and never needs to be repeated in each provider arm. result.map_err(|e| match e { AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")), + AgentError::LlmModelNotFound(s) => AgentError::LlmModelNotFound(format!("({effective_model}) {s}")), other => other, }) } @@ -1071,6 +1072,12 @@ where backoff_with_jitter(attempt).await; continue; } + if status == 404 { + return Err(AgentError::LlmModelNotFound(format!( + "{status}: {}", + read_error_body(resp).await + ))); + } if !status.is_success() { return Err(AgentError::Llm(format!( "{status}: {}", diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index ef006b70ac..d29e975e03 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -225,6 +225,7 @@ pub enum AgentError { InvalidParams(String), Llm(String), LlmAuth(String), + LlmModelNotFound(String), Mcp(String), Cancelled, } @@ -235,6 +236,7 @@ impl std::fmt::Display for AgentError { Self::InvalidParams(s) => write!(f, "invalid params: {s}"), Self::Llm(s) => write!(f, "llm: {s}"), Self::LlmAuth(s) => write!(f, "llm auth: {s}"), + Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"), Self::Mcp(s) => write!(f, "mcp: {s}"), Self::Cancelled => write!(f, "cancelled"), } @@ -248,6 +250,7 @@ impl AgentError { match self { Self::InvalidParams(_) => -32602, Self::LlmAuth(_) => -32001, + Self::LlmModelNotFound(_) => -32002, _ => -32000, } } diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 3716ea2127..8300d4acb9 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Manager, State}; use crate::{ app_state::AppState, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index f5cafaaeba..7282e3cc66 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -840,6 +840,7 @@ pub async fn create_managed_agent( last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: input.respond_to, respond_to_allowlist: respond_to_allowlist.clone(), display_name: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b32ea74f60..05f60eaaa4 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1198,6 +1198,7 @@ pub fn sync_managed_agent_processes( if let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) { record.updated_at = now_iso(); record.last_error = Some(format!("failed to inspect process state: {error}")); + record.last_error_code = None; } changed = true; exited.push(pubkey.clone()); @@ -1214,13 +1215,17 @@ pub fn sync_managed_agent_processes( record.runtime_pid = None; record.last_stopped_at = Some(now_iso()); record.last_exit_code = status.code(); - record.last_error = if status.success() { + let log_err = if status.success() { None } else { - super::meaningful_agent_error_from_log(&runtime.log_path) - .unwrap_or_else(|| format!("harness exited with status {status}")) - .into() + Some(super::meaningful_agent_error_from_log(&runtime.log_path) + .unwrap_or_else(|| super::storage::AgentLogError { + message: format!("harness exited with status {status}"), + code: None, + })) }; + record.last_error = log_err.as_ref().map(|e| e.message.clone()); + record.last_error_code = log_err.as_ref().and_then(|e| e.code); } changed = true; @@ -1409,6 +1414,7 @@ pub fn build_managed_agent_summary( last_stopped_at: record.last_stopped_at.clone(), last_exit_code: record.last_exit_code, last_error: record.last_error.clone(), + last_error_code: record.last_error_code, start_on_app_launch: record.start_on_app_launch, auto_restart_on_config_change: record.auto_restart_on_config_change, log_path, @@ -1927,6 +1933,7 @@ pub fn start_managed_agent_process( { record.updated_at = now_iso(); record.last_error = None; + record.last_error_code = None; return Ok(()); } @@ -1942,6 +1949,7 @@ pub fn start_managed_agent_process( record.last_stopped_at = None; record.last_exit_code = None; record.last_error = None; + record.last_error_code = None; runtimes.insert(record.pubkey.clone(), process); Ok(()) @@ -1964,6 +1972,7 @@ pub fn stop_managed_agent_process( record.last_stopped_at = Some(now); record.last_exit_code = None; record.last_error = None; + record.last_error_code = None; } super::remove_agent_pid_file(app, &record.pubkey); return Ok(()); @@ -1998,6 +2007,7 @@ pub fn stop_managed_agent_process( record.last_stopped_at = Some(now); record.last_exit_code = status.code(); record.last_error = None; + record.last_error_code = None; super::remove_agent_pid_file(app, &record.pubkey); diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 3d58057f8a..1bfcf40b9b 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -532,14 +532,31 @@ fn bytecount_newlines(buf: &[u8]) -> usize { buf.iter().filter(|&&b| b == b'\n').count() } -pub fn meaningful_agent_error_from_log(path: &Path) -> Option { +pub struct AgentLogError { + pub message: String, + pub code: Option, +} + +pub fn meaningful_agent_error_from_log(path: &Path) -> Option { let tail = read_log_tail(path, 200).ok()?; tail.lines().rev().map(str::trim).find_map(|line| { if line.starts_with("Agent reported error:") { - return Some(line.to_string()); + return Some(AgentLogError { + message: line.to_string(), + code: None, + }); } if line.starts_with("llm auth:") { - return Some(format!("Agent reported error: {line}")); + return Some(AgentLogError { + message: format!("Agent reported error: {line}"), + code: Some(-32001), + }); + } + if line.starts_with("llm model not found:") { + return Some(AgentLogError { + message: format!("Agent reported error: {line}"), + code: Some(-32002), + }); } None }) diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 8870ce40fe..9cc807bffa 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -110,6 +110,7 @@ impl PersonaRecord { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: RespondTo::default(), respond_to_allowlist: Vec::new(), display_name: Some(self.display_name), @@ -289,6 +290,8 @@ pub struct ManagedAgentRecord { pub last_stopped_at: Option, pub last_exit_code: Option, pub last_error: Option, + #[serde(default)] + pub last_error_code: Option, /// Inbound author gate mode. Translates to `BUZZ_ACP_RESPOND_TO`. #[serde(default)] pub respond_to: RespondTo, @@ -440,6 +443,7 @@ pub struct ManagedAgentSummary { pub last_stopped_at: Option, pub last_exit_code: Option, pub last_error: Option, + pub last_error_code: Option, pub start_on_app_launch: bool, pub auto_restart_on_config_change: bool, pub log_path: String, diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs index e81de8c2fd..92d28b72fa 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { friendlyAgentLastError, + MODEL_NOT_FOUND_COPY, RELAY_MESH_DENIED_COPY, } from "./friendlyAgentLastError.ts"; @@ -74,3 +75,52 @@ test("non-auth Agent reported error stays generic", () => { "Agent reported error: llm: 500 internal server error", ); }); + +test("code -32002 → model-not-found copy (severity: denied)", () => { + const result = friendlyAgentLastError( + "Agent reported error: llm model not found: (goose-claude-opus-4-8) 404 Not Found: ...", + -32002, + ); + assert.deepEqual(result, { + severity: "denied", + copy: MODEL_NOT_FOUND_COPY, + }); +}); + +test("code -32001 → relay mesh denied copy (structured path)", () => { + const result = friendlyAgentLastError("any error text", -32001); + assert.deepEqual(result, { + severity: "denied", + copy: RELAY_MESH_DENIED_COPY, + }); +}); + +test("code null falls through to legacy string matching", () => { + const result = friendlyAgentLastError( + "Agent reported error: llm auth: 401 unauthorized", + null, + ); + assert.deepEqual(result, { + severity: "denied", + copy: RELAY_MESH_DENIED_COPY, + }); +}); + +test("code undefined falls through to legacy string matching", () => { + const result = friendlyAgentLastError( + "Agent reported error: llm auth: 403 forbidden", + undefined, + ); + assert.deepEqual(result, { + severity: "denied", + copy: RELAY_MESH_DENIED_COPY, + }); +}); + +test("unknown code falls through to generic", () => { + const result = friendlyAgentLastError("some error", -99999); + assert.deepEqual(result, { + severity: "generic", + copy: "some error", + }); +}); diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts index 6060f511d7..35052c2f91 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts @@ -37,13 +37,27 @@ export type FriendlyAgentLastError = export const RELAY_MESH_DENIED_COPY = "Relay mesh denied this agent — check your relay membership."; +export const MODEL_NOT_FOUND_COPY = + "The configured model is not available — open agent settings and select a different one from the dropdown."; + export function friendlyAgentLastError( raw: string | null, + code?: number | null, ): FriendlyAgentLastError | null { if (raw == null) return null; const trimmed = raw.trim(); if (trimmed.length === 0) return null; + // Structured code path — no string matching, works across all providers. + if (code != null) { + switch (code) { + case -32001: + return { severity: "denied", copy: RELAY_MESH_DENIED_COPY }; + case -32002: + return { severity: "denied", copy: MODEL_NOT_FOUND_COPY }; + } + } + // Match either the unwrapped buzz-agent prefix or the buzz-acp wrap. // The desktop supervisor recovers whichever appears first in the log tail. if ( diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 116afbdfeb..71a523a143 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -88,7 +88,7 @@ export function ManagedAgentRow({ // friendly "Relay mesh denied this agent — check your relay membership." // for auth failures so the user knows it's a membership thing, not a // crash. Generic exits stay verbatim so we don't lie about other failures. - const friendlyError = friendlyAgentLastError(agent.lastError); + const friendlyError = friendlyAgentLastError(agent.lastError, agent.lastErrorCode); return (
Date: Wed, 8 Jul 2026 16:12:30 -0400 Subject: [PATCH 2/5] fix(agents): fix CI, missed struct sites, and make error codes harness-agnostic Embed the JSON-RPC code in AcpError::AgentError's Display format so the log scraper can extract it for any harness, not just buzz-agent. Replace the string-prefix heuristics with structured parsing of the (code N) field. Add last_error_code to all ManagedAgentRecord construction sites, fix cargo fmt / Biome formatting, and pass lastErrorCode at the second UnifiedAgentsSection call site. --- crates/buzz-acp/src/acp.rs | 21 ++++++++--- crates/buzz-agent/src/llm.rs | 4 +- .../src-tauri/src/commands/agent_config.rs | 1 + .../src/commands/personas/inbound_tests.rs | 1 + .../src/managed_agents/agent_events.rs | 1 + .../config_bridge/reader_tests.rs | 1 + .../src/managed_agents/discovery/tests.rs | 1 + .../src/managed_agents/nest/tests.rs | 1 + .../src-tauri/src/managed_agents/readiness.rs | 1 + .../src/managed_agents/relay_mesh.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 13 ++++--- .../src/managed_agents/runtime/tests.rs | 1 + .../src/managed_agents/spawn_hash/tests.rs | 1 + .../src-tauri/src/managed_agents/storage.rs | 37 +++++++++---------- .../src/managed_agents/team_repair.rs | 1 + .../agents/ui/UnifiedAgentsSection.tsx | 5 ++- .../agents/ui/agentSessionTranscript.ts | 3 +- 17 files changed, 59 insertions(+), 35 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 2a3999dd46..b527542628 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -101,7 +101,7 @@ pub enum AcpError { #[error("Protocol error: {0}")] Protocol(String), - #[error("Agent reported error: {message}")] + #[error("Agent reported error (code {code}): {message}")] AgentError { code: i64, message: String }, } @@ -864,8 +864,11 @@ impl AcpClient { if *id == serde_json::json!(expected_id) && msg.get("method").is_none() { if let Some(error) = msg.get("error") { let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-32000); - let message = error.get("message").and_then(|m| m.as_str()) - .unwrap_or("unknown error").to_string(); + let message = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error") + .to_string(); return Err(AcpError::AgentError { code, message }); } return Ok(msg["result"].clone()); @@ -1188,9 +1191,15 @@ impl AcpClient { let _ = ack_tx .send(crate::pool::SteerAck::PromptCompletedNeutral); } - let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-32000); - let message = error.get("message").and_then(|m| m.as_str()) - .unwrap_or("unknown error").to_string(); + let code = error + .get("code") + .and_then(|c| c.as_i64()) + .unwrap_or(-32000); + let message = error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("unknown error") + .to_string(); return Err(AcpError::AgentError { code, message }); } if let Some((_, ack_tx)) = pending_steer.take() { diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 6f524086ac..a85e3d77bc 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -149,7 +149,9 @@ impl Llm { // is centralized and never needs to be repeated in each provider arm. result.map_err(|e| match e { AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")), - AgentError::LlmModelNotFound(s) => AgentError::LlmModelNotFound(format!("({effective_model}) {s}")), + AgentError::LlmModelNotFound(s) => { + AgentError::LlmModelNotFound(format!("({effective_model}) {s}")) + } other => other, }) } diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 098e1ab7b3..bb9e2e581f 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -515,6 +515,7 @@ mod tests { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: RespondTo::OwnerOnly, respond_to_allowlist: vec![], display_name: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index 8d43e0282f..990f1c359a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -160,6 +160,7 @@ fn local_agent() -> ManagedAgentRecord { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: crate::managed_agents::RespondTo::OwnerOnly, respond_to_allowlist: vec![], display_name: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 30c0a88e34..f79770a2ba 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -175,6 +175,7 @@ mod tests { last_stopped_at: Some("2025-01-03T00:00:00Z".to_string()), last_exit_code: Some(0), last_error: Some("some runtime error".to_string()), + last_error_code: None, respond_to: RespondTo::Allowlist, respond_to_allowlist: vec!["79be667e".to_string()], // Unified-model fields carry real values so the exclusion test diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index c48be75d8a..dc5a5e287d 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -74,6 +74,7 @@ fn test_record() -> ManagedAgentRecord { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: crate::managed_agents::types::RespondTo::OwnerOnly, respond_to_allowlist: vec![], display_name: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 8311847578..8d649dca96 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -260,6 +260,7 @@ fn record_with( last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: Default::default(), respond_to_allowlist: vec![], display_name: None, diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 1da4f56845..a81c5b09db 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -467,6 +467,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: RespondTo::default(), respond_to_allowlist: vec![], env_vars: std::collections::BTreeMap::new(), diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index b45e872552..10b2fd1298 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -989,6 +989,7 @@ mod tests { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: Default::default(), respond_to_allowlist: vec![], display_name: None, diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 22ab9ad3b3..0787b4717c 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -97,6 +97,7 @@ mod tests { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: RespondTo::OwnerOnly, respond_to_allowlist: vec![], relay_mesh: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 05f60eaaa4..c096e40646 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1218,11 +1218,14 @@ pub fn sync_managed_agent_processes( let log_err = if status.success() { None } else { - Some(super::meaningful_agent_error_from_log(&runtime.log_path) - .unwrap_or_else(|| super::storage::AgentLogError { - message: format!("harness exited with status {status}"), - code: None, - })) + Some( + super::meaningful_agent_error_from_log(&runtime.log_path).unwrap_or_else( + || super::storage::AgentLogError { + message: format!("harness exited with status {status}"), + code: None, + }, + ), + ) }; record.last_error = log_err.as_ref().map(|e| e.message.clone()); record.last_error_code = log_err.as_ref().and_then(|e| e.code); diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 7c17a6f274..626ceb4379 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -159,6 +159,7 @@ fn fixture( last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to, respond_to_allowlist: allowlist, display_name: None, diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 0ab0efb5be..7ff4fe0e25 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -40,6 +40,7 @@ fn record() -> ManagedAgentRecord { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: Default::default(), respond_to_allowlist: vec![], display_name: None, diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 1bfcf40b9b..166766d865 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -540,24 +540,23 @@ pub struct AgentLogError { pub fn meaningful_agent_error_from_log(path: &Path) -> Option { let tail = read_log_tail(path, 200).ok()?; tail.lines().rev().map(str::trim).find_map(|line| { + // New format: "Agent reported error (code -32002): ..." + if let Some(rest) = line.strip_prefix("Agent reported error (code ") { + if let Some(paren_end) = rest.find("): ") { + let code = rest[..paren_end].parse::().ok(); + return Some(AgentLogError { + message: line.to_string(), + code, + }); + } + } + // Legacy format (older buzz-acp builds): "Agent reported error: ..." if line.starts_with("Agent reported error:") { return Some(AgentLogError { message: line.to_string(), code: None, }); } - if line.starts_with("llm auth:") { - return Some(AgentLogError { - message: format!("Agent reported error: {line}"), - code: Some(-32001), - }); - } - if line.starts_with("llm model not found:") { - return Some(AgentLogError { - message: format!("Agent reported error: {line}"), - code: Some(-32002), - }); - } None }) } @@ -874,20 +873,18 @@ mod tests { #[test] fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() { - let file = write_log("noise\nAgent reported error: llm auth: denied\n"); - assert_eq!( - super::meaningful_agent_error_from_log(file.path()).as_deref(), - Some("Agent reported error: llm auth: denied") + let file = write_log( + "noise\nAgent reported error (code -32001): llm auth: 401 unauthorized: ...\n", ); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert!(result.message.contains("llm auth")); + assert_eq!(result.code, Some(-32001)); } #[test] fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() { let file = write_log("noise\nllm auth: denied\n"); - assert_eq!( - super::meaningful_agent_error_from_log(file.path()).as_deref(), - Some("Agent reported error: llm auth: denied") - ); + assert!(super::meaningful_agent_error_from_log(file.path()).is_none()); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/team_repair.rs b/desktop/src-tauri/src/managed_agents/team_repair.rs index 7968330f04..7777cb8722 100644 --- a/desktop/src-tauri/src/managed_agents/team_repair.rs +++ b/desktop/src-tauri/src/managed_agents/team_repair.rs @@ -307,6 +307,7 @@ mod tests { last_stopped_at: None, last_exit_code: None, last_error: None, + last_error_code: None, respond_to: Default::default(), respond_to_allowlist: vec![], env_vars: std::collections::BTreeMap::new(), diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index bd83d340a7..c035bfbbd0 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -374,7 +374,10 @@ function StandaloneAgentCard({ }) { const title = agent.name; const profileQuery = useUserProfileQuery(agent.pubkey); - const friendlyError = friendlyAgentLastError(agent.lastError)?.copy; + const friendlyError = friendlyAgentLastError( + agent.lastError, + agent.lastErrorCode, + )?.copy; const isActive = isManagedAgentActive(agent); const opensRuntimeTab = Boolean(friendlyError && !isActive); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 4a9a9143f2..21bd80bd19 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -772,8 +772,7 @@ export function processTranscriptEvent( const payload = asRecord(event.payload); const outcome = asString(payload.outcome) ?? "error"; const error = asString(payload.error) ?? "Unknown error"; - const errorCode = - payload.code != null ? Number(payload.code) : null; + const errorCode = payload.code != null ? Number(payload.code) : null; const friendly = friendlyAgentLastError(error, errorCode); const displayError = friendly ? friendly.copy : error; const title = From d993dff04483c3e1ae15c274722eb1d51a6ff2a8 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 8 Jul 2026 16:39:41 -0400 Subject: [PATCH 3/5] fix(agents): biome format ManagedAgentRow friendlyAgentLastError call --- desktop/src/features/agents/ui/ManagedAgentRow.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 71a523a143..eeb300c33d 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -88,7 +88,10 @@ export function ManagedAgentRow({ // friendly "Relay mesh denied this agent — check your relay membership." // for auth failures so the user knows it's a membership thing, not a // crash. Generic exits stay verbatim so we don't lie about other failures. - const friendlyError = friendlyAgentLastError(agent.lastError, agent.lastErrorCode); + const friendlyError = friendlyAgentLastError( + agent.lastError, + agent.lastErrorCode, + ); return (
Date: Wed, 8 Jul 2026 16:59:33 -0400 Subject: [PATCH 4/5] refactor(agents): extract friendlyTurnErrorCopy to satisfy file-size guard agentSessionTranscript.ts crossed its 1167-line ceiling. Move the turn_error code coercion + copy fallback into the tested lib as a helper, shrinking the transcript hot path back under the limit. --- .../lib/friendlyAgentLastError.test.mjs | 24 +++++++++++++++++++ .../agents/lib/friendlyAgentLastError.ts | 10 ++++++++ .../agents/ui/agentSessionTranscript.ts | 6 ++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs index 92d28b72fa..4f36ea5183 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { friendlyAgentLastError, + friendlyTurnErrorCopy, MODEL_NOT_FOUND_COPY, RELAY_MESH_DENIED_COPY, } from "./friendlyAgentLastError.ts"; @@ -124,3 +125,26 @@ test("unknown code falls through to generic", () => { copy: "some error", }); }); + +test("friendlyTurnErrorCopy: numeric code -32002 → model-not-found copy", () => { + assert.equal( + friendlyTurnErrorCopy("raw error", -32002), + MODEL_NOT_FOUND_COPY, + ); +}); + +test("friendlyTurnErrorCopy: string-encoded code coerces to number", () => { + assert.equal( + friendlyTurnErrorCopy("raw error", "-32001"), + RELAY_MESH_DENIED_COPY, + ); +}); + +test("friendlyTurnErrorCopy: missing code falls back to raw text", () => { + assert.equal(friendlyTurnErrorCopy("raw error", undefined), "raw error"); + assert.equal(friendlyTurnErrorCopy("raw error", null), "raw error"); +}); + +test("friendlyTurnErrorCopy: unknown code passes raw text through", () => { + assert.equal(friendlyTurnErrorCopy("raw error", 12345), "raw error"); +}); diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts index 35052c2f91..57a9b3fbb1 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts @@ -69,3 +69,13 @@ export function friendlyAgentLastError( return { severity: "generic", copy: trimmed }; } + +/** + * Convenience for `turn_error` / `agent_panic` observer payloads: coerce the + * payload's untyped `code` JSON value and return the display copy, falling + * back to the raw error text when no classification applies. + */ +export function friendlyTurnErrorCopy(raw: string, code: unknown): string { + const numeric = code == null ? null : Number(code); + return friendlyAgentLastError(raw, numeric)?.copy ?? raw; +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 21bd80bd19..d7fe83432e 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -26,7 +26,7 @@ import { parsePromptText, parseSystemPromptSections, } from "./agentSessionTranscriptHelpers"; -import { friendlyAgentLastError } from "../lib/friendlyAgentLastError"; +import { friendlyTurnErrorCopy } from "../lib/friendlyAgentLastError"; export { describeRawEvent } from "./agentSessionTranscriptHelpers"; @@ -772,9 +772,7 @@ export function processTranscriptEvent( const payload = asRecord(event.payload); const outcome = asString(payload.outcome) ?? "error"; const error = asString(payload.error) ?? "Unknown error"; - const errorCode = payload.code != null ? Number(payload.code) : null; - const friendly = friendlyAgentLastError(error, errorCode); - const displayError = friendly ? friendly.copy : error; + const displayError = friendlyTurnErrorCopy(error, payload.code); const title = event.kind === "agent_panic" ? "Agent error (crash)" : "Turn error"; upsertTextItem( From 5e12292f94008f121e3c09a5b1689cb1c3091011 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 8 Jul 2026 17:07:59 -0400 Subject: [PATCH 5/5] test(desktop): add screenshot spec for agent error state badges Adds `agent-error-state-screenshots.spec.ts` capturing the three agent card error badge states added in the provider-error classification work: model-not-found (code -32002), generic error, and side-by-side. Extends the e2eBridge mock to carry `last_error_code` on seeded managed agents so specs can drive the structured code path without string matching. --- desktop/playwright.config.ts | 1 + desktop/src/testing/e2eBridge.ts | 5 + .../e2e/agent-error-state-screenshots.spec.ts | 140 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 1 + 4 files changed, 147 insertions(+) create mode 100644 desktop/tests/e2e/agent-error-state-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 440d8fac56..5ac23bd2af 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ "**/activity-scope-label-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", "**/agent-readiness-screenshots.spec.ts", + "**/agent-error-state-screenshots.spec.ts", "**/edit-agent.spec.ts", "**/pubkey-display-screenshots.spec.ts", "**/file-attachment.spec.ts", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index eff1708ee9..6b176b64dc 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -65,6 +65,7 @@ type MockManagedAgentSeed = { channelIds?: string[]; backend?: RawManagedAgent["backend"]; lastError?: string | null; + lastErrorCode?: number | null; respondTo?: RawManagedAgent["respond_to"]; respondToAllowlist?: string[]; }; @@ -460,6 +461,7 @@ type RawManagedAgent = { last_stopped_at: string | null; last_exit_code: number | null; last_error: string | null; + last_error_code: number | null; log_path: string; start_on_app_launch: boolean; auto_restart_on_config_change?: boolean; @@ -1042,6 +1044,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { last_stopped_at: agent.last_stopped_at, last_exit_code: agent.last_exit_code, last_error: agent.last_error, + last_error_code: agent.last_error_code, log_path: agent.log_path, start_on_app_launch: agent.start_on_app_launch, auto_restart_on_config_change: agent.auto_restart_on_config_change ?? true, @@ -1557,6 +1560,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { last_stopped_at: status === "stopped" ? now : null, last_exit_code: null, last_error: seed.lastError ?? null, + last_error_code: seed.lastErrorCode ?? null, log_path: `/tmp/mock-agent-${seed.pubkey}.log`, start_on_app_launch: true, auto_restart_on_config_change: true, @@ -6628,6 +6632,7 @@ async function handleCreateManagedAgent( last_stopped_at: null, last_exit_code: null, last_error: null, + last_error_code: null, log_path: `/tmp/mock-agent-${pubkey}.log`, start_on_app_launch: args.input.startOnAppLaunch ?? true, auto_restart_on_config_change: true, diff --git a/desktop/tests/e2e/agent-error-state-screenshots.spec.ts b/desktop/tests/e2e/agent-error-state-screenshots.spec.ts new file mode 100644 index 0000000000..51cb93e27f --- /dev/null +++ b/desktop/tests/e2e/agent-error-state-screenshots.spec.ts @@ -0,0 +1,140 @@ +/** + * Screenshot spec for structured agent provider error states (PR #1653). + * + * Exercises the two visible surfaces where friendly error copy appears: + * - Agent card avatar badge (CircleAlert icon + tooltip) for stopped agents + * with a lastError / lastErrorCode. + * + * ManagedAgentRow (StatusBlock text) is also exercised here even though it is + * not yet wired into a reachable route in the main app — it will be connected + * in the follow-up config-bridge PR. We render it in isolation by navigating + * to the agents view and letting the mock bridge expose the row through the + * unified section once that wiring lands; for now we capture the card badges + * which ARE reachable in the current build. + */ + +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const SHOTS = "test-results/pr-1653-screenshots"; + +// Two stopped agents — one with a structured -32002 code, one with a raw string. +const MODEL_NOT_FOUND_AGENT = { + pubkey: TEST_IDENTITIES.alice.pubkey, + name: "Databricks Agent", + status: "stopped" as const, + lastError: + "Agent reported error: llm: (goose-databricks-llama-3-3-70b) 404 Not Found: model not found", + lastErrorCode: -32002, +}; + +const GENERIC_ERROR_AGENT = { + pubkey: TEST_IDENTITIES.bob.pubkey, + name: "Local Agent", + status: "stopped" as const, + lastError: "harness exited with status 1", + lastErrorCode: null, +}; + +async function gotoAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("open-agents-view")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-library-personas")).toBeVisible({ + timeout: 10_000, + }); +} + +test.describe("agent error state screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + }); + + // Shot 01: agent card with model-not-found error badge (red CircleAlert). + // The badge title shows the friendly structured copy instead of the raw + // JSON error string. + test("01-model-not-found-error-badge", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [MODEL_NOT_FOUND_AGENT], + }); + + await gotoAgentsView(page); + + // Wait for the error badge to appear (stopped agent with error). + const errorBadge = page.getByTestId( + `agent-runtime-error-${MODEL_NOT_FOUND_AGENT.pubkey}`, + ); + await expect(errorBadge).toBeVisible({ timeout: 10_000 }); + await waitForAnimations(page); + + // Capture the agent card element. + const agentCard = page.getByTestId( + `managed-agent-${MODEL_NOT_FOUND_AGENT.pubkey}`, + ); + await agentCard.screenshot({ + path: `${SHOTS}/01-model-not-found-error-badge.png`, + }); + }); + + // Shot 02: agent card with generic (unclassified) error badge. + // The badge is present but the tooltip shows the raw exit string, not + // structured copy — demonstrating the error is still surfaced for any + // harness exit. + test("02-generic-error-badge", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [GENERIC_ERROR_AGENT], + }); + + await gotoAgentsView(page); + + const errorBadge = page.getByTestId( + `agent-runtime-error-${GENERIC_ERROR_AGENT.pubkey}`, + ); + await expect(errorBadge).toBeVisible({ timeout: 10_000 }); + await waitForAnimations(page); + + const agentCard = page.getByTestId( + `managed-agent-${GENERIC_ERROR_AGENT.pubkey}`, + ); + await agentCard.screenshot({ + path: `${SHOTS}/02-generic-error-badge.png`, + }); + }); + + // Shot 03: side-by-side — both agents in the same view so the reviewer can + // see error badges on all stopped agents in a real usage context. + test("03-agents-section-both-errors", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [MODEL_NOT_FOUND_AGENT, GENERIC_ERROR_AGENT], + }); + + await gotoAgentsView(page); + + // Wait for both error badges to be present. + await expect( + page.getByTestId(`agent-runtime-error-${MODEL_NOT_FOUND_AGENT.pubkey}`), + ).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByTestId(`agent-runtime-error-${GENERIC_ERROR_AGENT.pubkey}`), + ).toBeVisible(); + await waitForAnimations(page); + + // Capture the full agents section (scroll-bounded crop to the section). + const section = page.getByTestId("agents-library-personas"); + await section.screenshot({ + path: `${SHOTS}/03-agents-section-both-errors.png`, + }); + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d1571e6bc8..3a5df57768 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -53,6 +53,7 @@ type MockManagedAgentSeed = { | { type: "local" } | { type: "provider"; id: string; config: Record }; lastError?: string | null; + lastErrorCode?: number | null; respondTo?: "owner-only" | "allowlist" | "anyone"; respondToAllowlist?: string[]; };