diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index 852e32500b2..623403c6c4c 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -469,6 +469,39 @@ describe('codex journal translation', () => { expect(timeline).toEqual([]) }) + it('renders a system error carried by a suppressed status kind', () => { + const { translator, tap } = translatorWith() + + translator.handle( + notification('thread/status/changed', { + threadId: THREAD_ID, + status: { type: 'systemError' } + }) + ) + + const timeline = projectStructuredItemsToNativeChat( + tap.rows.map((row, index) => ({ + itemId: row.key, + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: row.body + })) + ) + expect(timeline).toEqual([ + expect.objectContaining({ + role: 'system', + blocks: [ + expect.objectContaining({ + providerFrame: expect.objectContaining({ + kind: 'notification:thread/status/changed' + }) + }) + ] + }) + ]) + }) + it('writes nothing more after dispose', () => { const { translator, tap, window } = translatorWith() diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 838b7af9155..f284efda051 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -1,5 +1,50 @@ export type ProviderFrameDisposition = 'debug-only' | 'timeline' +const ERROR_VARIANT_KEYS = new Set(['type', 'status', 'state', 'subtype', 'outcome']) +const ERROR_VALUE_KEYS = new Set(['error', 'failureReason', 'failure_reason']) + +function isErrorVariant(value: unknown): boolean { + if (typeof value !== 'string') { + return false + } + const normalized = value.replace(/[_\s-]/g, '').toLowerCase() + return ( + normalized.startsWith('error') || normalized.startsWith('fail') || normalized === 'systemerror' + ) +} + +function hasProviderError(payload: unknown): boolean { + const pending = [payload] + const seen = new WeakSet() + while (pending.length > 0) { + const value = pending.pop() + if (typeof value !== 'object' || value === null || seen.has(value)) { + continue + } + seen.add(value) + if (Array.isArray(value)) { + pending.push(...value) + continue + } + for (const [key, nested] of Object.entries(value)) { + if ((key === 'isError' || key === 'is_error') && nested === true) { + return true + } + if (key === 'success' && nested === false) { + return true + } + if (ERROR_VARIANT_KEYS.has(key) && isErrorVariant(nested)) { + return true + } + if (ERROR_VALUE_KEYS.has(key) && nested !== null && nested !== false && nested !== '') { + return true + } + pending.push(nested) + } + } + return false +} + function notificationKind(kind: string): string { return kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind } @@ -27,11 +72,25 @@ function isClaudeStatusFrame(kind: string): boolean { ) } -export function classifyProviderFrame(provider: string, kind: string): ProviderFrameDisposition { +export function classifyProviderFrame( + provider: string, + kind: string, + payload: unknown +): ProviderFrameDisposition { + if (hasProviderError(payload)) { + return 'timeline' + } if (provider === 'codex' && isCodexStatusFrame(kind)) { return 'debug-only' } if (provider === 'claude' && isClaudeStatusFrame(kind)) { + if (kind === 'message:result') { + const record = + typeof payload === 'object' && payload !== null + ? (payload as Record) + : null + return record?.subtype === 'success' ? 'debug-only' : 'timeline' + } return 'debug-only' } return 'timeline' diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts index 70a10947d54..d3701122547 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { projectStructuredItemToNativeChat } from '../../../shared/structured-agent-session-projection' import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame' describe('unhandled provider frame journal fallback', () => { @@ -63,7 +64,72 @@ describe('unhandled provider frame journal fallback', () => { unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', {}) ).toBeNull() expect(unhandledProviderFrameJournalItem('claude', 'message:system:init', {})).toBeNull() - expect(unhandledProviderFrameJournalItem('claude', 'message:result', {})).toBeNull() + expect( + unhandledProviderFrameJournalItem('claude', 'message:result', { + subtype: 'success', + is_error: false + }) + ).toBeNull() + }) + + it('renders codex systemError and Claude error result variants', () => { + const codex = unhandledProviderFrameJournalItem('codex', 'notification:thread/status/changed', { + threadId: 'thread-1', + status: { type: 'systemError' } + }) + const claude = unhandledProviderFrameJournalItem('claude', 'message:result', { + subtype: 'error_during_execution', + is_error: true, + result: 'Provider request failed' + }) + + expect(codex?.body.providerFrame).toMatchObject({ + provider: 'codex', + kind: 'notification:thread/status/changed' + }) + expect(claude?.body.providerFrame).toMatchObject({ + provider: 'claude', + kind: 'message:result' + }) + expect( + claude + ? projectStructuredItemToNativeChat({ + itemId: 'claude-error', + revision: 1, + sequence: 1, + observedAt: 1, + body: claude.body + }) + : null + ).toMatchObject({ + role: 'system', + blocks: [ + expect.objectContaining({ + providerFrame: expect.objectContaining({ kind: 'message:result' }) + }) + ] + }) + }) + + it('keeps failed startup variants visible while suppressing startup progress', () => { + const kind = 'notification:mcpServer/startupStatus/updated' + + expect( + unhandledProviderFrameJournalItem('codex', kind, { + name: 'filesystem', + status: 'starting', + error: null, + failureReason: null + }) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', kind, { + name: 'filesystem', + status: 'failed', + error: 'server exited', + failureReason: null + }) + ).not.toBeNull() }) it('keeps unknown substantive frames visible for both providers', () => { diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index f79a4f70ac3..0dc275bed50 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -27,7 +27,7 @@ export function unhandledProviderFrameJournalItem( payload: unknown, limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS ): UnhandledProviderFrameJournalItem | null { - if (classifyProviderFrame(provider, kind) === 'debug-only') { + if (classifyProviderFrame(provider, kind, payload) === 'debug-only') { return null } const serialized = serializeProviderPayload(payload)