fix(agents): surface provider errors structurally instead of raw dumps (#1653)

This commit is contained in:
Will Pfleger
2026-07-08 17:32:48 -04:00
committed by GitHub
parent b2c63291f1
commit 9b244ff22b
32 changed files with 373 additions and 35 deletions
+19 -4
View File
@@ -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 _ =
+20 -8
View File
@@ -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);
}
}
+1 -1
View File
@@ -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();
+9
View File
@@ -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}: {}",
+3
View File
@@ -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,
}
}
+1
View File
@@ -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,
+1
View File
@@ -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,
+27 -13
View File
@@ -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,
+2
View File
@@ -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,
+1
View File
@@ -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;
+5
View File
@@ -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`,
});
});
});
+1
View File
@@ -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[];
};