mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(agents): surface provider errors structurally instead of raw dumps (#1653)
This commit is contained in:
@@ -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 _ =
|
||||
|
||||
@@ -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<i64>| {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}: {}",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"**/doctor-cta-screenshots.spec.ts",
|
||||
"**/pubkey-display-screenshots.spec.ts",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -200,6 +200,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1168,6 +1168,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -1929,6 +1938,7 @@ pub fn start_managed_agent_process(
|
||||
{
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = None;
|
||||
record.last_error_code = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1944,6 +1954,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(())
|
||||
@@ -1966,6 +1977,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(());
|
||||
@@ -2000,6 +2012,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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> {
|
||||
pub struct AgentLogError {
|
||||
pub message: String,
|
||||
pub code: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn meaningful_agent_error_from_log(path: &Path) -> Option<AgentLogError> {
|
||||
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::<i64>().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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<String>,
|
||||
pub last_exit_code: Option<i32>,
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_error_code: Option<i64>,
|
||||
/// 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<String>,
|
||||
pub last_exit_code: Option<i32>,
|
||||
pub last_error: Option<String>,
|
||||
pub last_error_code: Option<i64>,
|
||||
pub start_on_app_launch: bool,
|
||||
pub auto_restart_on_config_change: bool,
|
||||
pub log_path: String,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -304,7 +304,7 @@ function AgentPersonaCard({
|
||||
? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl)
|
||||
: persona.avatarUrl;
|
||||
const friendlyError = agent
|
||||
? friendlyAgentLastError(agent.lastError)?.copy
|
||||
? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy
|
||||
: null;
|
||||
const opensRuntimeTab = Boolean(agent && friendlyError && !isActive);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
parsePromptText,
|
||||
parseSystemPromptSections,
|
||||
} from "./agentSessionTranscriptHelpers";
|
||||
import { friendlyTurnErrorCopy } from "../lib/friendlyAgentLastError";
|
||||
|
||||
export { describeRawEvent } from "./agentSessionTranscriptHelpers";
|
||||
|
||||
@@ -771,6 +772,7 @@ export function processTranscriptEvent(
|
||||
const payload = asRecord(event.payload);
|
||||
const outcome = asString(payload.outcome) ?? "error";
|
||||
const error = asString(payload.error) ?? "Unknown error";
|
||||
const displayError = friendlyTurnErrorCopy(error, payload.code);
|
||||
const title =
|
||||
event.kind === "agent_panic" ? "Agent error (crash)" : "Turn error";
|
||||
upsertTextItem(
|
||||
@@ -778,7 +780,7 @@ export function processTranscriptEvent(
|
||||
`${event.kind}:${ch}:${event.turnId ?? event.seq}`,
|
||||
"lifecycle",
|
||||
title,
|
||||
`${outcome}: ${error}`,
|
||||
`${outcome}: ${displayError}`,
|
||||
event.timestamp,
|
||||
ctx,
|
||||
event.kind,
|
||||
|
||||
@@ -226,6 +226,7 @@ export 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;
|
||||
@@ -1004,6 +1005,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
||||
lastStoppedAt: agent.last_stopped_at,
|
||||
lastExitCode: agent.last_exit_code,
|
||||
lastError: agent.last_error,
|
||||
lastErrorCode: agent.last_error_code ?? null,
|
||||
logPath: agent.log_path,
|
||||
startOnAppLaunch: agent.start_on_app_launch,
|
||||
autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true,
|
||||
|
||||
@@ -416,6 +416,7 @@ export type ManagedAgent = {
|
||||
lastStoppedAt: string | null;
|
||||
lastExitCode: number | null;
|
||||
lastError: string | null;
|
||||
lastErrorCode: number | null;
|
||||
logPath: string;
|
||||
startOnAppLaunch: boolean;
|
||||
autoRestartOnConfigChange: boolean;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ type MockManagedAgentSeed = {
|
||||
| { type: "local" }
|
||||
| { type: "provider"; id: string; config: Record<string, unknown> };
|
||||
lastError?: string | null;
|
||||
lastErrorCode?: number | null;
|
||||
respondTo?: "owner-only" | "allowlist" | "anyone";
|
||||
respondToAllowlist?: string[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user