diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts index e513655cd8f..7c636545daf 100644 --- a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -96,7 +96,8 @@ describe('grouping a Codex thread restored from full history', () => { role: message.role, rendersProse: true, outlivesTurn: false, - reportsTurnOutcome: false + reportsFailure: false, + reportsCompaction: false })), settledTurnKeys: new Set(bars.settledTurns.keys()), expandedTurnKeys: new Set() diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 4278f216e87..add069deb97 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -241,24 +241,38 @@ describe('a turn no message opened', () => { expect(toolSlot).toMatchObject({ folded: true, turnFolds: true, turnKey: 'wake' }) }) - it('keeps a row that reports its turn ending visible in a folded turn', () => { + const failure = (id: string): NativeChatMessage => ({ + ...text(id, 'The agent exited unexpectedly.', 'system'), + blocks: [{ type: 'text' as const, text: 'The agent exited unexpectedly.', tone: 'error' }] + }) + + it('ends a failed turn on its error, folding the prose before it', () => { const messages = [ text('u1', 'go', 'user'), + text('a1', 'Looking.'), toolRun('work'), - { - ...text('exit', 'The agent exited unexpectedly.', 'system'), - blocks: [{ type: 'text' as const, text: 'The agent exited unexpectedly.', tone: 'error' }] - } + failure('exit') ] const slots = build(messages, { turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } }) - // The folded work takes no slot; the report of the end still does. + // Folded rows take no slot; the error is the turn's visible end. expect(slots.map((slot) => [slot.message.id, slot.folded])).toEqual([ ['u1', false], ['exit', false] ]) }) + + it('folds an error the agent recovered from behind the answer that followed it', () => { + const messages = [text('u1', 'go', 'user'), failure('retry'), text('a1', 'Done.')] + const slots = build(messages, { + turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } + }) + expect(slots.map((slot) => [slot.message.id, slot.folded])).toEqual([ + ['u1', false], + ['a1', false] + ]) + }) }) describe('the live turn', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 7ec4a81653d..96989aab0f0 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -113,9 +113,11 @@ export function buildNativeChatTranscriptSlots( outlivesTurn: message.blocks.some( (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) ), - reportsTurnOutcome: message.blocks.some( - (block) => - block.type === 'text' && (block.tone === 'error' || block.presentation === 'compaction') + reportsFailure: message.blocks.some( + (block) => block.type === 'text' && block.tone === 'error' + ), + reportsCompaction: message.blocks.some( + (block) => block.type === 'text' && block.presentation === 'compaction' ), ...(agentId === null ? {} : { agentId }) } diff --git a/src/shared/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index 7608bb3864b..8d718df0be1 100644 --- a/src/shared/native-chat-turn-fold.test.ts +++ b/src/shared/native-chat-turn-fold.test.ts @@ -11,7 +11,8 @@ function row(overrides: Partial = {}): NativeChatTurnFold role: 'assistant', rendersProse: true, outlivesTurn: false, - reportsTurnOutcome: false, + reportsFailure: false, + reportsCompaction: false, ...overrides } } @@ -25,6 +26,9 @@ const TURN: NativeChatTurnFoldRow[] = [ row() ] +/** An error-toned system row: the agent or host reporting the turn failed. */ +const FAILURE = row({ role: 'system', reportsFailure: true }) + const SETTLED = new Set(['turn-1']) const NONE = new Set() @@ -43,6 +47,21 @@ describe('nativeChatTurnAnswerRows', () => { expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(1) }) + it('names a failure report as the answer when nothing the agent said comes after it', () => { + const rows = [row({ role: 'user' }), row(), FAILURE] + expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(2) + }) + + it('names the later prose as the answer when the agent recovered from a failure', () => { + const rows = [row({ role: 'user' }), FAILURE, row()] + expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(2) + }) + + it("keeps the session's own answer when a subagent's failure comes after it", () => { + const rows = [row({ role: 'user' }), row(), row({ ...FAILURE, agentId: 'sub-1' })] + expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(1) + }) + it('reports no answer for a turn that only ran tools', () => { const rows = [row({ role: 'user' }), row({ rendersProse: false })] expect(nativeChatTurnAnswerRows(rows).has('turn-1')).toBe(false) @@ -148,13 +167,45 @@ describe('nativeChatTurnFold', () => { expect(foldedRows.size).toBe(0) }) - it('never folds a row reporting how its turn ended, and keeps the last prose as the answer', () => { + it('ends a failed turn on its error, folding the work and prose before it', () => { + const rows = [row({ role: 'user' }), row(), row({ rendersProse: false }), FAILURE] + const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({ + rows, + settledTurnKeys: SETTLED, + expandedTurnKeys: NONE + }) + expect([...foldedRows].sort()).toEqual([1, 2]) + expect([...foldableTurnKeys]).toEqual(['turn-1']) + }) + + it('folds an error the agent recovered from behind the answer that followed it', () => { + const rows = [row({ role: 'user' }), row({ rendersProse: false }), FAILURE, row()] + const { foldedRows } = nativeChatTurnFold({ + rows, + settledTurnKeys: SETTLED, + expandedTurnKeys: NONE + }) + expect([...foldedRows].sort()).toEqual([1, 2]) + }) + + it('shows a turn that is nothing but its error as that error, with no disclosure', () => { + const rows = [row({ role: 'user' }), FAILURE] + const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({ + rows, + settledTurnKeys: SETTLED, + expandedTurnKeys: NONE + }) + expect(foldedRows.size).toBe(0) + expect(foldableTurnKeys.size).toBe(0) + }) + + it('never folds a compaction report, and keeps the last prose as the answer', () => { const rows = [ row({ role: 'user' }), row(), - row({ role: 'system', rendersProse: true, reportsTurnOutcome: true }), + row({ role: 'system', reportsCompaction: true }), row(), - row({ role: 'system', rendersProse: true, reportsTurnOutcome: true }) + row({ role: 'system', reportsCompaction: true }) ] const { foldedRows } = nativeChatTurnFold({ rows, diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 9c97e2eea58..056360b7817 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -1,13 +1,10 @@ // Which rows a settled turn folds behind its "Worked for N" status row. // -// A turn's answer is its last assistant row that renders prose; everything the -// agent said before it is the work that produced it. The journal carries no -// "this one is the answer" marker on a message, so the answer is derived rather -// than read — last prose row wins. A provider that starts publishing one can -// override this derivation without moving the fold. -// -// Shared because desktop and mobile both draw this disclosure, and a fold that -// hides a different row on each surface is the same bug twice. +// A turn's answer is its last assistant row that renders prose, or its last +// failure report; everything before it is the work that produced it. The journal +// carries no "this one is the answer" marker on a message, so the answer is +// derived rather than read — last candidate wins. So a failed turn ends on its +// error, and an error the agent recovered from folds behind the answer after it. import { isRootAgentJournalItem } from './agent-session-journal-producer' import type { NativeChatRole } from './native-chat-types' @@ -25,9 +22,12 @@ export type NativeChatTurnFoldRow = { * spawn roster or a background task. That row is the durable report of how * the work ended — often the only one — so it never folds. */ outlivesTurn: boolean - /** Whether the row reports how its turn ended — an error, or a compaction's result. A fold - * that hid it would leave the turn's status as the only trace of why it stopped. */ - reportsTurnOutcome: boolean + /** Whether a system row reports a failure. It competes with prose to be the + * turn's answer, so a turn that failed shows the error as its end. */ + reportsFailure: boolean + /** Whether the row reports a compaction's result. It never folds: hiding it + * would leave the turn's status as the only trace the context was rewritten. */ + reportsCompaction: boolean /** The subagent that produced the row. Absent ⇒ the session's own agent. */ agentId?: string } @@ -46,20 +46,19 @@ export const NATIVE_CHAT_EMPTY_TURN_FOLD: NativeChatTurnFold = { } /** The index of each turn's answer: the last prose the session's own agent - * wrote. A subagent that is still narrating after its parent's last word would - * otherwise stand as the turn's only visible reply. A turn with no such row has - * no answer, and folds whole. */ + * wrote, or its last system row reporting a failure. A subagent that is still + * narrating after its parent's last word would otherwise stand as the turn's + * only visible reply. A turn with neither has no answer, and folds whole. */ export function nativeChatTurnAnswerRows( rows: readonly NativeChatTurnFoldRow[] ): ReadonlyMap { const answers = new Map() for (const [index, row] of rows.entries()) { - if ( - row.turnKey !== undefined && - row.role === 'assistant' && + const isCandidate = row.rendersProse && - isRootAgentJournalItem(row) - ) { + isRootAgentJournalItem(row) && + (row.role === 'assistant' || (row.role === 'system' && row.reportsFailure)) + if (row.turnKey !== undefined && isCandidate) { answers.set(row.turnKey, index) } } @@ -90,12 +89,12 @@ export function nativeChatTurnFold({ for (const [index, row] of rows.entries()) { const { turnKey } = row // Outside the fold by construction: the reader's own message anchors the - // turn, a roster or background-task row outlives it, and an end report explains it. + // turn, a roster or background-task row outlives it, and a compaction report explains it. if ( turnKey === undefined || row.role === 'user' || row.outlivesTurn || - row.reportsTurnOutcome || + row.reportsCompaction || !settledTurnKeys.has(turnKey) ) { continue