diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 31e64334acb..04f22c16b06 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -273,7 +273,9 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { } } const unhandled = unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item) - return unhandled ? { ...unhandled, handled: false } : { body: null, blobs: [], handled: true } + return unhandled + ? { body: unhandled.body, blobs: unhandled.blobs, handled: false } + : { body: null, blobs: [], handled: true } } export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null { diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts index b27eafec7fb..cd0e626887a 100644 --- a/src/main/codex/codex-structured-journal-translation.test.ts +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -502,7 +502,7 @@ describe('codex journal translation', () => { ).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified']) }) - it('bounds generic rows per turn while preserving unknown non-delta evidence', () => { + it('bounds generic rows per turn while keeping the suppression visible and countable', () => { const { translator, tap } = translatorWith() translator.handle(TURN_STARTED) for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN + 20; index += 1) { @@ -510,11 +510,26 @@ describe('codex journal translation', () => { } translator.handle(notification('item/future/outputDelta', { itemId: 'future', delta: 'x' })) - expect(tap.rows).toHaveLength(MAX_CODEX_GENERIC_ROWS_PER_TURN) - expect(tap.rows[0]?.body).toMatchObject({ + const generic = tap.rows.filter( + (row) => row.body.kind === 'status' && row.body.providerFrame !== undefined + ) + expect(generic).toHaveLength(MAX_CODEX_GENERIC_ROWS_PER_TURN) + expect(generic[0]?.body).toMatchObject({ kind: 'status', providerFrame: { kind: 'notification:future/notification' } }) + // The 20 capped frames reduce to ONE summary row whose count is exact, so + // suppressed provider activity is never invisible. + const summaries = new Map( + tap.rows + .filter((row) => row.key.includes('provider-frame-suppressed')) + .map((row) => [row.key, row.body]) + ) + expect(summaries.size).toBe(1) + expect([...summaries.values()][0]).toEqual({ + kind: 'status', + text: '20 more provider notifications not shown for this turn' + }) expect( tap.rows.some( (row) => @@ -524,6 +539,23 @@ describe('codex journal translation', () => { ).toBe(false) }) + it('never lets the generic-row cap hide an error frame', () => { + const { translator, tap } = translatorWith() + translator.handle(TURN_STARTED) + for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN + 3; index += 1) { + translator.handle(notification('future/notification', { value: index })) + } + translator.handle(notification('future/failure', { error: 'provider exploded' })) + + expect( + tap.rows.some( + (row) => + row.body.kind === 'status' && + row.body.providerFrame?.kind === 'notification:future/failure' + ) + ).toBe(true) + }) + it('keeps a fresh session timeline empty through startup and status notifications', () => { const { translator, tap } = translatorWith() diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index fa227901765..4de04769fdb 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -66,6 +66,7 @@ export function createCodexJournalTranslator( const details = new Map() const currentTurnIds = new Map() const genericRowsByTurn = new Map() + const suppressedRowsByTurn = new Map() let fallbackSequence = 0 const appendUnhandled = (kind: string, payload: unknown, threadId = 'session'): void => { @@ -76,7 +77,21 @@ export function createCodexJournalTranslator( const turnId = readCodexTurnId(payload) ?? currentTurnIds.get(threadId) ?? 'outside-turn' const bucket = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}` const rowCount = genericRowsByTurn.get(bucket) ?? 0 - if (rowCount >= MAX_CODEX_GENERIC_ROWS_PER_TURN) { + // The cap bounds noise, never evidence: an error frame is always journaled, + // and capped frames stay countable through one summary row per turn. + const capped = + rowCount >= MAX_CODEX_GENERIC_ROWS_PER_TURN && translated.classification !== 'error-surface' + if (capped) { + const suppressed = (suppressedRowsByTurn.get(bucket) ?? 0) + 1 + suppressedRowsByTurn.set(bucket, suppressed) + deps.sink.appendItem( + { provider: 'orca', clientMessageId: `provider-frame-suppressed:codex:${bucket}` }, + { + kind: 'status', + text: `${suppressed} more provider notification${suppressed === 1 ? '' : 's'} not shown for this turn` + } + ) + deps.sink.publish() return } genericRowsByTurn.set(bucket, rowCount + 1) @@ -276,6 +291,7 @@ export function createCodexJournalTranslator( details.clear() currentTurnIds.clear() genericRowsByTurn.clear() + suppressedRowsByTurn.clear() } } } 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 8496c973a95..8d11df995a6 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 @@ -225,12 +225,15 @@ export function classifyProviderFrame( kind: string, payload: unknown ): ProviderFrameClassification { - if (isDeltaShapedProviderFrameKind(kind)) { - return 'stream-into-item' - } + // Payload failure inspection outranks the name-shape heuristic below: an + // unknown frame that reports an error must reach the user even when its + // method name happens to look like a stream delta. if (hasProviderError(payload)) { return 'error-surface' } + if (isDeltaShapedProviderFrameKind(kind)) { + return 'stream-into-item' + } if (provider === 'claude' && kind === 'message:result') { const subtype = typeof payload === 'object' && payload !== null 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 9de8ee78ea9..a8c93191504 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 @@ -73,7 +73,7 @@ describe('unhandled provider frame journal fallback', () => { ).toBeNull() }) - it('never creates generic rows for known or unknown delta-shaped frames', () => { + it('never creates generic rows for delta-shaped frames that report no failure', () => { expect( unhandledProviderFrameJournalItem('codex', 'notification:item/commandExecution/outputDelta', { itemId: 'exec-1', @@ -82,11 +82,25 @@ describe('unhandled provider frame journal fallback', () => { ).toBeNull() expect( unhandledProviderFrameJournalItem('codex', 'notification:item/future/outputDelta', { - error: 'still a delta' + itemId: 'future-1', + delta: 'y' }) ).toBeNull() }) + it('surfaces an unknown delta-shaped frame whose payload reports an error', () => { + const row = unhandledProviderFrameJournalItem('codex', 'notification:item/future/outputDelta', { + error: 'stream broke mid-item' + }) + + expect(row).not.toBeNull() + expect(row?.classification).toBe('error-surface') + expect(row?.body.providerFrame).toMatchObject({ + provider: 'codex', + kind: 'notification:item/future/outputDelta' + }) + }) + it('renders codex systemError and Claude error result variants', () => { const codex = unhandledProviderFrameJournalItem('codex', 'notification:thread/status/changed', { threadId: 'thread-1', 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 32d2e32809c..c81137342d9 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 @@ -10,6 +10,8 @@ import { classifyProviderFrame } from './provider-frame-disposition' export type UnhandledProviderFrameJournalItem = { body: AgentJournalStatusItem blobs: { digest: string; payload: string }[] + /** Why the frame surfaced. Error frames are exempt from generic-row caps. */ + classification: 'timeline-substantive' | 'error-surface' } function serializeProviderPayload(payload: unknown): string { @@ -85,6 +87,7 @@ export function unhandledProviderFrameJournalItem( text: display?.text ?? `${provider} ยท ${kind}`, providerFrame: { provider, kind, payload: bounded } }, - blobs: bounded.truncated ? [{ digest: bounded.digest, payload: serialized }] : [] + blobs: bounded.truncated ? [{ digest: bounded.digest, payload: serialized }] : [], + classification: classification === 'error-surface' ? 'error-surface' : 'timeline-substantive' } }