diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 356a6d3b..fc63f72d 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -216,6 +216,22 @@ export function normalizeAgentRecord( meta } } + if (data.type === 'system' && data.subtype === 'api_error') { + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: { + type: 'api-error', + retryAttempt: asNumber(data.retryAttempt) ?? 0, + maxRetries: asNumber(data.maxRetries) ?? 0, + error: data.error + }, + isSidechain: false, + meta + } + } return null } diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index 0412aa54..33ed40e7 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -13,6 +13,19 @@ export type EventPresentation = { } export function getEventPresentation(event: AgentEvent): EventPresentation { + if (event.type === 'api-error') { + const { retryAttempt, maxRetries } = event as { retryAttempt: number; maxRetries: number } + if (maxRetries > 0 && retryAttempt >= maxRetries) { + return { icon: '⚠️', text: 'API error: Max retries reached' } + } + if (maxRetries > 0) { + return { icon: '⏳', text: `API error: Retrying (${retryAttempt}/${maxRetries})` } + } + if (retryAttempt > 0) { + return { icon: '⏳', text: 'API error: Retrying...' } + } + return { icon: '⚠️', text: 'API error' } + } if (event.type === 'switch') { const mode = event.mode === 'local' ? 'local' : 'remote' return { icon: '🔄', text: `Switched to ${mode}` } diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index ae7de1be..79df2c79 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,7 +1,7 @@ import type { AgentState } from '@/types/api' import type { ChatBlock, NormalizedMessage, UsageData } from '@/chat/types' import { traceMessages, type TracedMessage } from '@/chat/tracer' -import { dedupeAgentEvents } from '@/chat/reducerEvents' +import { dedupeAgentEvents, foldApiErrorEvents } from '@/chat/reducerEvents' import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools' import { reduceTimeline } from '@/chat/reducerTimeline' @@ -94,5 +94,5 @@ export function reduceChatBlocks( } } - return { blocks: dedupeAgentEvents(rootResult.blocks), hasReadyEvent, latestUsage } + return { blocks: dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks)), hasReadyEvent, latestUsage } } diff --git a/web/src/chat/reducerEvents.ts b/web/src/chat/reducerEvents.ts index 66f9a718..1d896562 100644 --- a/web/src/chat/reducerEvents.ts +++ b/web/src/chat/reducerEvents.ts @@ -1,4 +1,4 @@ -import type { AgentEvent, ChatBlock, NormalizedMessage } from '@/chat/types' +import type { AgentEvent, AgentEventBlock, ChatBlock, NormalizedMessage } from '@/chat/types' function parseClaudeUsageLimit(text: string): number | null { const match = text.match(/^Claude AI usage limit reached\|(\d+)$/) @@ -83,3 +83,32 @@ export function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] { return result } + +/** + * Fold consecutive api-error events, keeping only the latest state. + */ +export function foldApiErrorEvents(blocks: ChatBlock[]): ChatBlock[] { + const result: ChatBlock[] = [] + + for (const block of blocks) { + if (block.kind !== 'agent-event') { + result.push(block) + continue + } + + const event = block.event as { type: string } + if (event.type !== 'api-error') { + result.push(block) + continue + } + + const prev = result[result.length - 1] as AgentEventBlock | undefined + if (prev?.kind === 'agent-event' && (prev.event as { type: string }).type === 'api-error') { + result[result.length - 1] = block + } else { + result.push(block) + } + } + + return result +} diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 6f0472b9..51435514 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -14,6 +14,7 @@ export type AgentEvent = | { type: 'title-changed'; title: string } | { type: 'limit-reached'; endsAt: number } | { type: 'ready' } + | { type: 'api-error'; retryAttempt: number; maxRetries: number; error: unknown } | ({ type: string } & Record) export type ToolResultPermission = {