From 3dce75f8ccf6dd5dd3ad4a7a783e221a6cd7bc44 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Jul 2026 07:28:43 -0700 Subject: [PATCH 1/2] fix(buzz-acp): add error_class to turn_error observer payload Classify turn failures (exited, timeout, transport, agent_error, etc.) so the desktop can render actionable error badges instead of collapsing all PromptOutcome::Error variants to a generic outcome string. Co-authored-by: Cursor Signed-off-by: Jeremy --- crates/buzz-acp/src/lib.rs | 152 ++++++++++++++++++++++++++++++++++--- 1 file changed, 141 insertions(+), 11 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 8627d1d42a..79c11bcd64 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2669,6 +2669,37 @@ fn dispatch_pending( dispatched_channels } +/// True when the stdio pipe may be corrupted — caller should respawn the agent. +fn is_acp_transport_error(e: &acp::AcpError) -> bool { + matches!( + e, + acp::AcpError::Io(_) + | acp::AcpError::WriteTimeout(_) + | acp::AcpError::Timeout(_) + | acp::AcpError::Protocol(_) + ) +} + +/// Stable classifier for `turn_error` observer payloads. Distinct from +/// `outcome`, which collapses all `PromptOutcome::Error` variants to `"error"`. +fn turn_error_class(outcome: &PromptOutcome) -> &'static str { + match outcome { + PromptOutcome::AgentExited => "exited", + PromptOutcome::Timeout => "timeout", + PromptOutcome::Error(e) => match e { + acp::AcpError::IdleTimeout(_) => "idle_timeout", + acp::AcpError::HardTimeout => "hard_timeout", + acp::AcpError::AgentError { .. } => "agent_error", + // serde parse failure on a wire line — not the same as `Protocol` (transport). + acp::AcpError::Json(_) => "protocol", + acp::AcpError::AgentExited => "exited", + e if is_acp_transport_error(e) => "transport", + _ => "error", + }, + _ => "error", + } +} + #[allow(clippy::too_many_arguments)] fn handle_prompt_result( pool: &mut AgentPool, @@ -2782,10 +2813,12 @@ fn handle_prompt_result( PromptSource::Channel(ch) => Some(*ch), PromptSource::Heartbeat => None, }; + let error_class = turn_error_class(&result.outcome); 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_class": error_class, "error": error_msg, }); if let Some(code) = error_code { @@ -2868,13 +2901,7 @@ fn handle_prompt_result( pool.return_agent(result.agent); } PromptOutcome::Error(ref e) => { - let is_transport_error = matches!( - e, - acp::AcpError::Io(_) - | acp::AcpError::WriteTimeout(_) - | acp::AcpError::Timeout(_) - | acp::AcpError::Protocol(_) - ); + let is_transport_error = is_acp_transport_error(e); let error_code = match &e { acp::AcpError::AgentError { code, .. } => Some(*code), _ => None, @@ -3957,6 +3984,58 @@ mod build_mcp_servers_tests { } } +#[cfg(test)] +mod turn_error_class_tests { + use super::{is_acp_transport_error, turn_error_class}; + use crate::acp::AcpError; + use crate::pool::PromptOutcome; + + #[test] + fn classifies_all_failure_variants() { + assert_eq!(turn_error_class(&PromptOutcome::AgentExited), "exited"); + assert_eq!(turn_error_class(&PromptOutcome::Timeout), "timeout"); + assert_eq!( + turn_error_class(&PromptOutcome::Error(AcpError::IdleTimeout( + std::time::Duration::from_secs(1) + ))), + "idle_timeout" + ); + assert_eq!( + turn_error_class(&PromptOutcome::Error(AcpError::HardTimeout)), + "hard_timeout" + ); + assert_eq!( + turn_error_class(&PromptOutcome::Error(AcpError::AgentError { + code: -32001, + message: "llm auth".into(), + })), + "agent_error" + ); + assert_eq!( + turn_error_class(&PromptOutcome::Error(AcpError::AgentExited)), + "exited" + ); + + let json_err = + AcpError::Json(serde_json::from_str::("not json").unwrap_err()); + assert!(!is_acp_transport_error(&json_err)); + assert_eq!( + turn_error_class(&PromptOutcome::Error(json_err)), + "protocol" + ); + + for err in [ + AcpError::Io(std::io::Error::other("pipe")), + AcpError::WriteTimeout(std::time::Duration::from_secs(1)), + AcpError::Timeout(std::time::Duration::from_secs(1)), + AcpError::Protocol("desync".into()), + ] { + assert!(is_acp_transport_error(&err), "{err:?} should be transport"); + assert_eq!(turn_error_class(&PromptOutcome::Error(err)), "transport"); + } + } +} + #[cfg(test)] mod error_outcome_emission_tests { //! Pins the policy that error-class outcomes surface to the activity feed @@ -4041,9 +4120,7 @@ mod error_outcome_emission_tests { } } - /// Drive one error outcome through `handle_prompt_result` and return how - /// many `turn_error` events it emitted to the observer feed. - async fn turn_errors_emitted_for(outcome: PromptOutcome) -> usize { + async fn drive_prompt_outcome(outcome: PromptOutcome, observer: &ObserverHandle) { let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); @@ -4074,7 +4151,6 @@ mod error_outcome_emission_tests { }]; let (respawn_tx, _respawn_rx) = mpsc::channel(8); let mut respawn_tasks = tokio::task::JoinSet::new(); - let observer = ObserverHandle::in_process(); let result = PromptResult { agent, @@ -4096,7 +4172,13 @@ mod error_outcome_emission_tests { Some(observer.clone()), None, ); + } + /// Drive one error outcome through `handle_prompt_result` and return how + /// many `turn_error` events it emitted to the observer feed. + async fn turn_errors_emitted_for(outcome: PromptOutcome) -> usize { + let observer = ObserverHandle::in_process(); + drive_prompt_outcome(outcome, &observer).await; observer .snapshot() .iter() @@ -4104,6 +4186,24 @@ mod error_outcome_emission_tests { .count() } + async fn turn_error_payload_for(outcome: PromptOutcome) -> serde_json::Value { + let observer = ObserverHandle::in_process(); + drive_prompt_outcome(outcome, &observer).await; + observer + .snapshot() + .into_iter() + .find(|e| e.kind == "turn_error") + .expect("expected one turn_error event") + .payload + } + + fn payload_error_class(payload: &serde_json::Value) -> &str { + payload + .get("error_class") + .and_then(|value| value.as_str()) + .expect("turn_error payload must include error_class") + } + #[tokio::test] async fn agent_exited_emits_exactly_one_feed_event() { assert_eq!(turn_errors_emitted_for(PromptOutcome::AgentExited).await, 1); @@ -4125,6 +4225,36 @@ mod error_outcome_emission_tests { let app = AcpError::IdleTimeout(std::time::Duration::from_secs(1)); assert_eq!(turn_errors_emitted_for(PromptOutcome::Error(app)).await, 1); } + + #[tokio::test] + async fn turn_error_payload_includes_error_class_for_representative_outcomes() { + let cases = [ + (PromptOutcome::AgentExited, "exited", "exited"), + (PromptOutcome::Timeout, "timeout", "timeout"), + ( + PromptOutcome::Error(AcpError::AgentError { + code: -32001, + message: "llm auth: token expired".into(), + }), + "error", + "agent_error", + ), + ( + PromptOutcome::Error(AcpError::Io(std::io::Error::other("pipe broke"))), + "error", + "transport", + ), + ]; + + for (outcome, expected_outcome, expected_class) in cases { + let payload = turn_error_payload_for(outcome).await; + assert_eq!(payload_error_class(&payload), expected_class); + assert_eq!(payload["outcome"], expected_outcome); + if expected_class == "agent_error" { + assert_eq!(payload["code"], -32001); + } + } + } } #[cfg(test)] From 74e6441befda8bf18deffa093780faf6949a2f8e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Jul 2026 07:54:18 -0700 Subject: [PATCH 2/2] fix(desktop): preserve classified turn error badges until next success Keep turn_error tombstones in activeAgentTurnsStore with errorClass labels, surface them on agent/sidebar/profile badges, and use harness error_class for transcript titles. Errors clear on the next turn_started or turn_completed. Co-authored-by: Cursor Signed-off-by: Jeremy --- .../agents/activeAgentTurnsStore.test.mjs | 55 +++++ .../features/agents/activeAgentTurnsStore.ts | 205 ++++++++++++++++-- .../agents/agentWorkingSignal.test.mjs | 18 ++ .../src/features/agents/agentWorkingSignal.ts | 5 + .../lib/friendlyAgentLastError.test.mjs | 9 + .../agents/lib/friendlyAgentLastError.ts | 33 +++ .../features/agents/ui/AgentStatusBadge.tsx | 23 +- .../features/agents/ui/ManagedAgentRow.tsx | 32 ++- .../agents/ui/agentSessionTranscript.ts | 9 +- .../profile/ui/UserProfilePopover.tsx | 13 +- .../features/sidebar/ui/SidebarSection.tsx | 21 +- 11 files changed, 389 insertions(+), 34 deletions(-) diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index 75d216e2eb..8b58ea6e6d 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -321,6 +321,61 @@ describe("activeAgentTurnsStore", () => { }); }); + describe("turn_error tombstones", () => { + it("preserves classified error state until turn_started clears it", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), + makeEvent({ + seq: 2, + kind: "turn_error", + turnId: "t1", + channelId: "c1", + payload: { + error_class: "agent_error", + code: -32001, + error: "auth expired", + }, + }), + ]); + + const [summary] = getActiveTurnsForAgent(AGENT); + assert.equal(summary.channelId, "c1"); + assert.equal(summary.isError, true); + assert.equal(summary.errorLabel, "Auth error"); + + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: 3, + turnId: "t2", + channelId: "c1", + timestamp: "2024-01-01T00:00:02Z", + }), + ]); + + const [cleared] = getActiveTurnsForAgent(AGENT); + assert.equal(cleared.isError, undefined); + }); + + it("clears error tombstone on turn_completed", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }), + makeEvent({ + seq: 2, + kind: "turn_error", + turnId: "t1", + channelId: "c1", + payload: { error_class: "transport", error: "pipe broke" }, + }), + ]); + assert.equal(getActiveTurnsForAgent(AGENT)[0]?.isError, true); + + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 3, kind: "turn_completed", turnId: "t1", channelId: "c1" }), + ]); + assert.equal(getActiveTurnsForAgent(AGENT).length, 0); + }); + }); + describe("replay idempotency", () => { it("replaying the same buffer produces no additional state change or notifications", () => { const buffer = [ diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 9ffd34db63..70b6cf3a39 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -6,6 +6,7 @@ import { compareObserverEvents, } from "@/features/agents/observerRelayStore"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { turnErrorTitle } from "@/features/agents/lib/friendlyAgentLastError"; import type { ObserverEvent } from "./ui/agentSessionTypes"; /** Harness emits turn_liveness every ~10s (BUZZ_ACP_TURN_LIVENESS_SECS). */ @@ -36,12 +37,17 @@ type ActiveTurn = { channelId: string; startedAt: number; lastActivityAt: number; + isError?: boolean; + errorClass?: string; + errorCode?: number | null; }; /** One working channel surfaced to the UI, anchored to the desktop clock. */ export type ActiveTurnSummary = { channelId: string; anchorAt: number; + isError?: boolean; + errorLabel?: string; }; /** One channel with active agent work, aggregated across agents. */ @@ -51,6 +57,8 @@ export type ActiveChannelTurnSummary = { agentCount: number; agentPubkeys: string[]; agentNames?: string[]; + isError?: boolean; + errorLabel?: string; }; // Module-level state: agentPubkey → turnId → ActiveTurn @@ -123,6 +131,39 @@ function sampleClockOffset(agentKey: string, timestamp: string): boolean { return true; } +function readTurnErrorPayload(event: ObserverEvent): { + errorClass: string; + errorCode: number | null; +} { + const payload = + event.payload && + typeof event.payload === "object" && + !Array.isArray(event.payload) + ? (event.payload as Record) + : null; + const rawClass = payload?.error_class ?? payload?.errorClass; + const errorClass = + typeof rawClass === "string" && rawClass.length > 0 + ? rawClass + : event.kind === "agent_panic" + ? "panic" + : "error"; + const codeRaw = payload?.code; + const code = codeRaw == null ? null : Number(codeRaw); + return { + errorClass, + errorCode: Number.isFinite(code) ? (code as number) : null, + }; +} + +function clearErrorTurnsInChannel(agentTurns: Map, channelId: string) { + for (const [turnId, turn] of agentTurns) { + if (turn.channelId === channelId && turn.isError) { + agentTurns.delete(turnId); + } + } +} + function startTurn( agentPubkey: string, channelId: string, @@ -136,6 +177,9 @@ function startTurn( activeTurnsByAgent.set(key, agentTurns); } + // A successful new turn in this channel supersedes any error tombstone. + clearErrorTurnsInChannel(agentTurns, channelId); + // Cap at MAX_TURNS_PER_AGENT — evict oldest if exceeded if (agentTurns.size >= MAX_TURNS_PER_AGENT && !agentTurns.has(turnId)) { let oldestKey: string | null = null; @@ -161,6 +205,75 @@ function startTurn( invalidateCache(key); } +function markTurnError( + agentPubkey: string, + turnId: string | null, + channelId: string | null, + errorClass: string, + errorCode: number | null, + timestamp: string, +) { + const key = normalizePubkey(agentPubkey); + let agentTurns = activeTurnsByAgent.get(key); + if (!agentTurns) { + agentTurns = new Map(); + activeTurnsByAgent.set(key, agentTurns); + } + + const applyError = (turn: ActiveTurn) => { + turn.isError = true; + turn.errorClass = errorClass; + turn.errorCode = errorCode; + turn.lastActivityAt = Date.now(); + }; + + if (turnId) { + const existing = agentTurns.get(turnId); + if (existing) { + applyError(existing); + invalidateCache(key); + return; + } + } + + if (channelId) { + for (const turn of agentTurns.values()) { + if (turn.channelId === channelId) { + applyError(turn); + invalidateCache(key); + return; + } + } + + const syntheticTurnId = turnId ?? `error-${Date.parse(timestamp) || Date.now()}`; + if (!agentTurns.has(syntheticTurnId)) { + if (agentTurns.size >= MAX_TURNS_PER_AGENT) { + let oldestKey: string | null = null; + let oldestTime = Number.POSITIVE_INFINITY; + for (const [tid, turn] of agentTurns) { + if (turn.startedAt < oldestTime) { + oldestTime = turn.startedAt; + oldestKey = tid; + } + } + if (oldestKey) { + agentTurns.delete(oldestKey); + } + } + agentTurns.set(syntheticTurnId, { + turnId: syntheticTurnId, + channelId, + startedAt: Date.parse(timestamp) || Date.now(), + lastActivityAt: Date.now(), + isError: true, + errorClass, + errorCode, + }); + invalidateCache(key); + } + } +} + function recordActivity(agentPubkey: string, turnId: string | null): boolean { if (!turnId) return false; const key = normalizePubkey(agentPubkey); @@ -284,6 +397,10 @@ function pruneExpired() { let changed = false; for (const [agentKey, agentTurns] of activeTurnsByAgent) { for (const [turnId, turn] of agentTurns) { + // Error tombstones clear on the next successful turn, not on idle timeout. + if (turn.isError) { + continue; + } if (now - turn.lastActivityAt > REMOVE_AFTER_MS) { agentTurns.delete(turnId); invalidateCache(agentKey); @@ -340,8 +457,6 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { } break; case "turn_completed": - case "turn_error": - case "agent_panic": endTurn( agentPubkey, event.turnId ?? null, @@ -350,6 +465,20 @@ function processEvent(agentPubkey: string, event: ObserverEvent) { ); notifyListeners(); return; + case "turn_error": + case "agent_panic": { + const { errorClass, errorCode } = readTurnErrorPayload(event); + markTurnError( + agentPubkey, + event.turnId ?? null, + event.channelId ?? null, + errorClass, + errorCode, + event.timestamp, + ); + notifyListeners(); + return; + } case "acp_read": case "acp_write": // turn_liveness keeps a quiet-but-alive turn from being pruned; same @@ -416,21 +545,47 @@ export function getActiveTurnsForAgent( const offset = clockOffsetByAgent.get(key) ?? 0; - // Collapse multiple turns in one channel to the earliest start — the badge - // should count from when the channel's oldest live turn began. Anchors are - // derived here (startedAt + offset) so the latest skew estimate applies. - const earliestByChannel = new Map(); + // Collapse multiple turns in one channel. Error tombstones take precedence + // over working turns until the next successful turn clears them. + const summaryByChannel = new Map< + string, + { + startedAt: number; + anchorAt: number; + isError?: boolean; + errorLabel?: string; + } + >(); for (const turn of agentTurns.values()) { - const prior = earliestByChannel.get(turn.channelId); - if (prior === undefined || turn.startedAt < prior) { - earliestByChannel.set(turn.channelId, turn.startedAt); + const anchorAt = turn.startedAt + offset; + const existing = summaryByChannel.get(turn.channelId); + if (turn.isError) { + summaryByChannel.set(turn.channelId, { + startedAt: turn.startedAt, + anchorAt, + isError: true, + errorLabel: turnErrorTitle(turn.errorClass, turn.errorCode), + }); + continue; + } + if (existing?.isError) { + continue; + } + if (existing === undefined || turn.startedAt < existing.startedAt) { + summaryByChannel.set(turn.channelId, { + startedAt: turn.startedAt, + anchorAt, + }); } } - const result = [...earliestByChannel.entries()] - .map(([channelId, startedAt]) => ({ + const result = [...summaryByChannel.entries()] + .map(([channelId, summary]) => ({ channelId, - anchorAt: startedAt + offset, + anchorAt: summary.anchorAt, + ...(summary.isError + ? { isError: true as const, errorLabel: summary.errorLabel } + : {}), })) .sort((a, b) => a.channelId.localeCompare(b.channelId)); cachedTurnSummaries.set(key, result); @@ -450,7 +605,12 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { const summaries = new Map< string, - { anchorAt: number; agentPubkeys: Set } + { + anchorAt: number; + agentPubkeys: Set; + isError?: boolean; + errorLabel?: string; + } >(); for (const [agentKey, agentTurns] of activeTurnsByAgent) { @@ -464,12 +624,26 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { summaries.set(turn.channelId, { anchorAt, agentPubkeys: new Set([agentKey]), + ...(turn.isError + ? { + isError: true as const, + errorLabel: turnErrorTitle(turn.errorClass, turn.errorCode), + } + : {}), }); continue; } summary.agentPubkeys.add(agentKey); - if (anchorAt < summary.anchorAt) { + if (turn.isError) { + summary.isError = true; + summary.errorLabel = turnErrorTitle(turn.errorClass, turn.errorCode); + if (anchorAt < summary.anchorAt) { + summary.anchorAt = anchorAt; + } + continue; + } + if (!summary.isError && anchorAt < summary.anchorAt) { summary.anchorAt = anchorAt; } } @@ -481,6 +655,9 @@ export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] { anchorAt: summary.anchorAt, agentCount: summary.agentPubkeys.size, agentPubkeys: [...summary.agentPubkeys].sort(), + ...(summary.isError + ? { isError: true as const, errorLabel: summary.errorLabel } + : {}), })) .sort((a, b) => a.channelId.localeCompare(b.channelId)); cachedChannelTurnSummaries = result; diff --git a/desktop/src/features/agents/agentWorkingSignal.test.mjs b/desktop/src/features/agents/agentWorkingSignal.test.mjs index c134c3de60..ccf1c0b30e 100644 --- a/desktop/src/features/agents/agentWorkingSignal.test.mjs +++ b/desktop/src/features/agents/agentWorkingSignal.test.mjs @@ -212,4 +212,22 @@ describe("subscription and caching", () => { resetAgentWorkingSignal(); assert.equal(getAgentWorkingState(AGENT, "chan-1").working, false); }); + + it("surfaces observer error tombstones in working state", () => { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ seq: 1, turnId: "t1", channelId: "chan-1" }), + makeEvent({ + seq: 2, + kind: "turn_error", + turnId: "t1", + channelId: "chan-1", + payload: { error_class: "transport", error: "pipe broke" }, + }), + ]); + + const state = getAgentWorkingState(AGENT); + assert.equal(state.working, true); + assert.equal(state.channels[0]?.isError, true); + assert.equal(state.channels[0]?.errorLabel, "Transport error"); + }); }); diff --git a/desktop/src/features/agents/agentWorkingSignal.ts b/desktop/src/features/agents/agentWorkingSignal.ts index 1c3a74fc43..87675c1e32 100644 --- a/desktop/src/features/agents/agentWorkingSignal.ts +++ b/desktop/src/features/agents/agentWorkingSignal.ts @@ -35,6 +35,8 @@ export type AgentWorkingChannel = { /** Desktop-clock anchor for elapsed displays (turn start / first typing). */ anchorAt: number; source: Exclude; + isError?: boolean; + errorLabel?: string; }; export type AgentWorkingState = { @@ -141,6 +143,9 @@ function computeAgentWorkingState( channelId: turn.channelId, anchorAt: turn.anchorAt, source: "observer" as const, + ...(turn.isError + ? { isError: true as const, errorLabel: turn.errorLabel } + : {}), })); const observerChannelIds = new Set(turns.map((turn) => turn.channelId)); diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs index e1cc138447..fe3054de8f 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { friendlyAgentLastError, friendlyTurnErrorCopy, + turnErrorTitle, MODEL_NOT_FOUND_COPY, RELAY_MESH_DENIED_COPY, } from "./friendlyAgentLastError.ts"; @@ -213,3 +214,11 @@ test("friendlyTurnErrorCopy: garbage string code coerces to NaN → string path" RELAY_MESH_DENIED_COPY, ); }); + +test("turnErrorTitle: maps harness error_class values", () => { + assert.equal(turnErrorTitle("agent_error", -32001), "Auth error"); + assert.equal(turnErrorTitle("transport"), "Transport error"); + assert.equal(turnErrorTitle("idle_timeout"), "Timed out"); + assert.equal(turnErrorTitle("panic"), "Agent error (crash)"); + assert.equal(turnErrorTitle("error"), "Turn error"); +}); diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts index 38d37f5ae9..a7fd148188 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts @@ -96,3 +96,36 @@ export function friendlyTurnErrorCopy(raw: string, code: unknown): string { const safe = Number.isFinite(numeric) ? (numeric as number) : null; return friendlyAgentLastError(raw, safe)?.copy ?? raw; } + +/** Short headline for classified `turn_error` / `agent_panic` observer events. */ +export function turnErrorTitle( + errorClass: string | null | undefined, + code?: unknown, +): string { + const cls = errorClass?.trim() || "error"; + const numeric = code == null ? null : Number(code); + const safeCode = Number.isFinite(numeric) ? (numeric as number) : null; + + if (cls === "agent_error") { + if (safeCode === -32001) return "Auth error"; + if (safeCode === -32002) return "Model unavailable"; + return "Agent error"; + } + + switch (cls) { + case "transport": + return "Transport error"; + case "timeout": + case "idle_timeout": + case "hard_timeout": + return "Timed out"; + case "exited": + return "Agent exited"; + case "protocol": + return "Protocol error"; + case "panic": + return "Agent error (crash)"; + default: + return "Turn error"; + } +} diff --git a/desktop/src/features/agents/ui/AgentStatusBadge.tsx b/desktop/src/features/agents/ui/AgentStatusBadge.tsx index 15858b9ebd..d6592d33d8 100644 --- a/desktop/src/features/agents/ui/AgentStatusBadge.tsx +++ b/desktop/src/features/agents/ui/AgentStatusBadge.tsx @@ -11,11 +11,13 @@ export function AgentStatusBadge({ presenceLoaded, presenceStatus, status, + workingLabel, }: { isWorking?: boolean; presenceLoaded: boolean; presenceStatus: PresenceStatus | undefined; status: ManagedAgent["status"]; + workingLabel?: string; }) { const [inGracePeriod, setInGracePeriod] = React.useState(true); @@ -31,23 +33,28 @@ export function AgentStatusBadge({ status === "running" && (!presenceStatus || presenceStatus === "offline"); - const variant: "default" | "warning" | "secondary" = isWorking - ? "default" - : isStarting - ? "warning" - : isActive + const variant: "default" | "warning" | "secondary" | "destructive" = + isWorking && workingLabel + ? "destructive" + : isWorking ? "default" - : "secondary"; + : isStarting + ? "warning" + : isActive + ? "default" + : "secondary"; const label = isWorking - ? "Working" + ? (workingLabel ?? "Working") : isStarting ? "Starting\u2026" : status.replace(/_/g, " "); return ( {label} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index eeb300c33d..348a3d765f 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -65,15 +65,19 @@ export function ManagedAgentRow({ const activeWorkingChannels = React.useMemo( () => activeTurns - .map(({ channelId, anchorAt }) => ({ + .map(({ channelId, anchorAt, isError, errorLabel }) => ({ id: channelId, name: channelIdToName[channelId] ?? channelId, anchorAt, + isError, + errorLabel, })) .slice(0, 3), [activeTurns, channelIdToName], ); const isWorking = activeWorkingChannels.length > 0; + const workingStatusLabel = activeWorkingChannels.find((c) => c.isError) + ?.errorLabel; const processDetail = agent.pid !== null ? `PID ${agent.pid}` @@ -128,6 +132,7 @@ export function ManagedAgentRow({ presenceStatus={presenceStatus} processDetail={processDetail} status={agent.status} + workingStatusLabel={workingStatusLabel} /> @@ -151,6 +156,7 @@ export function ManagedAgentRow({ presenceStatus={presenceStatus} processDetail={processDetail} status={agent.status} + workingStatusLabel={workingStatusLabel} /> @@ -297,6 +303,8 @@ function AgentSummary({ channelId={channel.id} name={channel.name} anchorAt={channel.anchorAt} + isError={channel.isError} + errorLabel={channel.errorLabel} // Deep-link straight into the agent's activity pane in the // working channel, not just the channel timeline. onNavigate={(channelId) => @@ -317,16 +325,35 @@ function WorkingBadge({ name, anchorAt, onNavigate, + isError, + errorLabel, }: { channelId: string; name: string; anchorAt: number; onNavigate: (channelId: string) => void; + isError?: boolean; + errorLabel?: string; }) { // The 1s tick lives here, at the leaf, so only visible working badges // re-render each second — idle rows never mount this hook. const now = useNow(1000); + if (isError && errorLabel) { + return ( + { + e.stopPropagation(); + onNavigate(channelId); + }} + > + {errorLabel} in #{name} + + ); + } + return ( ; isWorking: boolean; @@ -355,6 +383,7 @@ function StatusBlock({ presenceStatus: PresenceStatus | undefined; processDetail: string; status: ManagedAgent["status"]; + workingStatusLabel?: string; }) { return (
@@ -366,6 +395,7 @@ function StatusBlock({ presenceLoaded={presenceLoaded} presenceStatus={presenceStatus} status={status} + workingLabel={workingStatusLabel} />

{processDetail}

{friendlyError ? ( diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index d7fe83432e..d5f507ba81 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 { friendlyTurnErrorCopy } from "../lib/friendlyAgentLastError"; +import { friendlyTurnErrorCopy, turnErrorTitle } from "../lib/friendlyAgentLastError"; export { describeRawEvent } from "./agentSessionTranscriptHelpers"; @@ -773,8 +773,11 @@ export function processTranscriptEvent( 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"; + const errorClass = asString(payload.error_class) ?? asString(payload.errorClass); + const title = turnErrorTitle( + errorClass ?? (event.kind === "agent_panic" ? "panic" : "error"), + payload.code, + ); upsertTextItem( d, `${event.kind}:${ch}:${event.turnId ?? event.seq}`, diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 6a293586f4..3a56b4cd6e 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -590,11 +590,12 @@ export function UserProfilePopover({ {activeTurns.length > 0 ? (
- {activeTurns.map(({ channelId, anchorAt }) => ( + {activeTurns.map(({ channelId, anchorAt, isError, errorLabel }) => ( ))}
@@ -729,12 +730,22 @@ export function UserProfilePopover({ function PopoverWorkingBadge({ name, anchorAt, + errorLabel, }: { name: string; anchorAt: number; + errorLabel?: string; }) { const now = useNow(1000); + if (errorLabel) { + return ( + + {errorLabel} in #{name} + + ); + } + return ( Working in #{name} · {formatElapsed(now - anchorAt)} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 84b4a7a27b..52817df3b6 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -131,17 +131,24 @@ function ChannelWorkingBadge({ }) { const now = useNow(1000); const elapsed = formatElapsed(now - summary.anchorAt); - const label = - summary.agentCount > 1 ? `${elapsed} (${summary.agentCount})` : elapsed; - const title = formatWorkingTooltip(summary); + const label = summary.isError + ? (summary.errorLabel ?? "Turn error") + : summary.agentCount > 1 + ? `${elapsed} (${summary.agentCount})` + : elapsed; + const title = summary.isError + ? `${summary.errorLabel ?? "Turn error"} in #${channelName}` + : formatWorkingTooltip(summary); return (