diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index ea11fce12f..b527542628 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 (code {code}): {message}")] + AgentError { code: i64, message: String }, } /// ACP client that owns an agent subprocess and communicates over its stdio. @@ -863,7 +863,13 @@ 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 +1191,16 @@ 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..a85e3d77bc 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -149,6 +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}")) + } other => other, }) } @@ -1071,6 +1074,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/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-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/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/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 b32ea74f60..c096e40646 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,20 @@ 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 +1417,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 +1936,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 +1952,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 +1975,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 +2010,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/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 3d58057f8a..166766d865 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -532,14 +532,30 @@ 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()); + // 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, + }); + } } - if line.starts_with("llm auth:") { - return Some(format!("Agent reported error: {line}")); + // 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, + }); } None }) @@ -857,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-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..4f36ea5183 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs @@ -3,6 +3,8 @@ import test from "node:test"; import { friendlyAgentLastError, + friendlyTurnErrorCopy, + MODEL_NOT_FOUND_COPY, RELAY_MESH_DENIED_COPY, } from "./friendlyAgentLastError.ts"; @@ -74,3 +76,75 @@ 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", + }); +}); + +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 6060f511d7..57a9b3fbb1 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 ( @@ -55,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/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 116afbdfeb..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); + const friendlyError = friendlyAgentLastError( + agent.lastError, + agent.lastErrorCode, + ); return (
{ + 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[]; };