From 400935a30d43d6db139387427de58e933b03c22b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:36:53 -0700 Subject: [PATCH 01/24] fix(native-chat): a chat a crash, quit or restart cut short reads Interrupted until its state changes A turn the host proved was cut short by a crash, quit, restart or eviction read red "Failed" on every status surface, while the chat's turn bar read "Worked for N". It now reads Interrupted everywhere: the sidebar dot and line, tab bar, Cmd+J, Activity, the notes menu, the phone's agent list, notifications ("stopped"), and the turn bar ("Interrupted after N", with the one derived notice that says why). A person's Stop is unchanged and gets no notice; a real failure stays red. The status is derived from the chat's latest turn, so it lasts until the next turn replaces it. The 30-minute freshness window no longer clears it on the sidebar card, tab bar or Cmd+J: a cut is the chat's journal state, not a live report that goes quiet. A departed agent's retained cut reads interrupted rather than done. --- .../MobileNativeChatTurnStatus.test.ts | 6 +-- mobile/src/worktree/agent-row-display.test.ts | 14 ++++--- mobile/src/worktree/agent-row-display.ts | 7 ++-- src/main/ipc/notification-options.ts | 7 ++-- .../notifications-message-formatting.test.ts | 4 +- ...ctured-agent-session-close-verdict.test.ts | 8 ++-- ...tured-agent-session-crash-turn-end.test.ts | 11 +++--- ...agent-session-recovered-turn-clock.test.ts | 16 ++++---- ...d-agent-session-stop-event-restart.test.ts | 23 ++++++----- ...ivityPrototypePage.thread-grouping.test.ts | 4 +- .../activity-status-group-header.test.ts | 5 +-- ...ivity-thread-grouping.status-order.test.ts | 6 +-- .../activity-thread-presentation.test.ts | 39 ++++++++++++++++++- .../activity/activity-thread-presentation.ts | 5 +-- .../dashboard/DashboardAgentRow.test.tsx | 11 ++++-- ...MessageList.interrupted-turn-fold.test.tsx | 6 +-- .../worktree-agent-activity-summary.test.ts | 36 ++++++++++++++++- .../worktree-agent-activity-summary.ts | 22 +++++++---- .../worktree-card-agent-summary.test.ts | 9 +++-- .../terminal-tab-activity-status.test.ts | 28 ++++++++++++- .../tab-bar/terminal-tab-activity-status.ts | 4 +- .../src/lib/agent-verdict-status-line.ts | 4 +- src/renderer/src/lib/pane-agent-evidence.ts | 20 ++++++++++ .../src/lib/recent-workspace-tab-rows.test.ts | 13 ++++--- .../src/lib/recent-workspace-tab-rows.ts | 5 +-- ...paired-agent-row-turn-outcome-arms.test.ts | 2 +- src/shared/agent-main-agent-verdict.test.ts | 32 ++++++++------- src/shared/agent-main-agent-verdict.ts | 12 +++--- src/shared/native-chat-turn-status.test.ts | 8 ++-- src/shared/native-chat-turn-status.ts | 8 ++-- ...s-version-worktree-ps-verdict.unit.test.ts | 3 +- 31 files changed, 252 insertions(+), 126 deletions(-) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index a0393996cec3..3116bf503cfe 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -78,15 +78,15 @@ describe('MobileNativeChatTurnStatus', () => { expect(onToggleExpanded).toHaveBeenCalledOnce() }) - // The crash's notice row explains the cut; the turn bar reads like any finished turn. - it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => { + // A crash cut reads interrupted, as a Stop does; the crash's notice row explains why. + it('heads a turn a crash cut off and a turn the user stopped alike as interrupted', () => { const crashed = render({ startedAt: Date.now(), workedSeconds: 12, verdict: 'interruption', onToggleExpanded: vi.fn() }) - expect(labels(crashed.root)).toEqual(['Worked for 12s']) + expect(labels(crashed.root)).toEqual(['Interrupted after 12s']) act(() => crashed.unmount()) const stopped = render({ startedAt: Date.now(), diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index c3fa8c84b6de..58f0490dc166 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -63,12 +63,13 @@ describe('agentDotState', () => { ) }) - it('reads a crash-cut turn as failed and an unproven end as unconfirmed', () => { + // A crash, quit or restart cut it short: the phone reads it interrupted, as desktop does. + it('reads a crash-cut turn as interrupted and an unproven end as unconfirmed', () => { expect(agentDotState(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe( - 'failed' + 'interrupted' ) expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe( - 'Failed' + 'Interrupted' ) expect(agentDotState(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe( 'unconfirmed' @@ -88,10 +89,11 @@ describe('agentDotState', () => { it('shows a main agent that failed while its subagents still run as failed', () => { expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed') expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed') + // Only a failure outranks live work; a success, a stop or a crash cut with live subagents + // reads working. expect(agentDotState(row({ state: 'working', ...mainAgentDone('interruption') }), 0)).toBe( - 'failed' + 'working' ) - // Only a fault outranks live work; a success or a stop with live subagents reads working. expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working') expect( agentDotState( @@ -143,7 +145,7 @@ describe('agentDotState', () => { expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done') expect( agentDotState(row({ state: 'done', updatedAt: 0, ...mainAgentDone('interruption') }), stale) - ).toBe('failed') + ).toBe('interrupted') }) }) diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 87b07abfbfb7..67eea807c2b3 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -49,18 +49,17 @@ export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome | return row.state === 'done' && row.interrupted ? 'cancellation' : null } -// A fault (a failure, or a turn cut short by something other than the user) reads failed and -// outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark -// only a row that is itself done. +// A failure reads failed and outranks every state; a turn that ended early (a user's Stop, a newer +// request, a crash, quit or restart) and an unproven end mark only a row that is itself done. export function agentRowVerdictMark( row: AgentRowVerdictSource ): 'failed' | 'interrupted' | 'unconfirmed' | null { switch (agentRowVerdict(row)) { case 'failure': - case 'interruption': return 'failed' case 'cancellation': case 'superseded': + case 'interruption': return row.state === 'done' ? 'interrupted' : null case 'unconfirmed': return row.state === 'done' ? 'unconfirmed' : null diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index b06987c4b75c..e910522f4f97 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -80,14 +80,13 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s return translateMain('notifications.agentStatus.finished', 'finished') } switch (args.agentTurnOutcome) { - // A turn cut short by anything but the user is a fault, as a failure is. case 'failure': - case 'interruption': return translateMain('notifications.agentStatus.failed', 'failed') - // Why: a Stop the user asked for, a turn a newer request replaced, or an end Orca cannot - // prove, still never reads finished. + // Why: a Stop the user asked for, a turn a newer request replaced or a crash cut short, or an + // end Orca cannot prove, still never reads finished. case 'cancellation': case 'superseded': + case 'interruption': case 'unconfirmed': return translateMain('notifications.agentStatus.stopped', 'stopped') case 'success': diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index bf96745fb4fb..0781fa0e99f1 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -319,8 +319,8 @@ describe('registerNotificationHandlers', () => { it.each([ { agentTurnOutcome: 'cancellation', word: 'stopped' }, { agentTurnOutcome: 'failure', word: 'failed' }, - // A turn cut short by anything but the user is a fault, worded as one. - { agentTurnOutcome: 'interruption', word: 'failed' }, + // A crash, quit or restart cut it short: worded as the Stop it reads as, not a fault. + { agentTurnOutcome: 'interruption', word: 'stopped' }, { agentTurnOutcome: 'unconfirmed', word: 'stopped' }, { agentTurnOutcome: 'superseded', word: 'stopped' }, { agentTurnOutcome: 'success', word: 'finished' }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index b1922b913ed1..30085cfca15b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -130,7 +130,7 @@ async function settledTurn() { return { turn, settled, label, notices } } -/** A turn nobody stopped reads like a finished one, with exactly one row saying it stopped. */ +/** A turn nobody stopped reads interrupted, with exactly one row saying why it stopped. */ const ONE_NOTICE = [ 'Codex stopped while this response was in progress. You can continue in this conversation.' ] @@ -164,8 +164,8 @@ describe('a turn cut short by closing its provider', () => { const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - // News for the sidebar; the turn reads like a finished one beside its one notice. - expect(label).toBe('workedFor') + // Interrupted, as a Stop is, beside the one notice that says why. + expect(label).toBe('interruptedAfter') expect(notices).toEqual(ONE_NOTICE) }) @@ -259,7 +259,7 @@ describe('a turn cut short by closing its provider', () => { session: { status: 'idle', turnOutcome: 'interruption' } }) const { label, notices } = await settledTurn() - expect(label).toBe('workedFor') + expect(label).toBe('interruptedAfter') expect(notices).toEqual(ONE_NOTICE) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index d31a79e5f54b..52dc50ded545 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -217,7 +217,7 @@ describe('a turn a crash cut short mid-tool', () => { expect(completedStructuredAgentTurnSeconds(timing)).toBe(27) }) - // The turn bar reads like a finished turn, so this row is the one place the chat says why. + // The turn bar says only that it was interrupted, so this row is the one place the chat says why. it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) @@ -242,7 +242,7 @@ describe('a turn a crash cut short mid-tool', () => { workedSeconds: completedStructuredAgentTurnSeconds(timing), verdict: timing?.verdict }) - ).toEqual({ key: 'workedFor', duration: '27s' }) + ).toEqual({ key: 'interruptedAfter', duration: '27s' }) }) it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => { @@ -327,7 +327,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () unsubscribe() }) - it('reports the revision to the status feed as an interruption, which the chat folds as worked', async () => { + it('reports the revision to the status feed as an interruption, which the chat folds as interrupted', async () => { const published: AgentSessionStatusSummary[] = [] openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }), @@ -343,7 +343,8 @@ describe('a turn a read reached before the reconcile proved its owner dead', () await host.reconcileRestartLeases() await drainSession() - // The sidebar's red Failed until seen; the turn folds as "Worked for 27s" beside its notice row. + // Interrupted in the sidebar until the chat's state changes; the turn folds as "Interrupted after + // 27s" beside its notice row. await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption')) const [timing] = selectStructuredAgentTurnTimings( (await host.journalSnapshot(SESSION)).items @@ -354,7 +355,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () workedSeconds: completedStructuredAgentTurnSeconds(timing), verdict: timing?.verdict }) - ).toEqual({ key: 'workedFor', duration: '27s' }) + ).toEqual({ key: 'interruptedAfter', duration: '27s' }) }) it('revises nothing twice, whoever re-runs the settle', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 0857e3565d69..ce133785e51d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -145,14 +145,14 @@ describe('a turn recovery settled after its host went away', () => { 'an exit observed before the restart', { state: 'interrupted', completedAt: EXIT_OBSERVED }, 'interruption', - // A turn the user did not stop is a fault, marked as a failure is. - 'failed' + // A turn a crash, quit or restart cut short reads interrupted, as a Stop does. + 'interrupted' ] ] satisfies [ string, StructuredAgentSessionTurnVerdict, 'unconfirmed' | 'interruption', - 'unconfirmed' | 'failed' + 'unconfirmed' | 'interrupted' ][])( 'is done as of the recovery with the end the host observed, never a success: %s', async (_label, verdict, outcome, mark) => { @@ -183,8 +183,8 @@ describe('a turn recovery settled after its host went away', () => { } ) - // The tab's mark reads a turn nobody stopped as failed; the chat's turn bar reads it like a - // finished turn, since the chat's notice row says why it stopped. + // The tab's mark and the chat's turn bar both read a turn nobody stopped as interrupted; the + // chat's notice row says why it stopped. it.each([ [ 'a restart', @@ -208,7 +208,7 @@ describe('a turn recovery settled after its host went away', () => { ) ] ] as const)( - 'reads Worked for N, marked failed, for a turn cut off by %s', + 'reads Interrupted after N, marked interrupted, for a turn cut off by %s', async (_label, cut) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -216,12 +216,12 @@ describe('a turn recovery settled after its host went away', () => { session.publish() const [row] = session.server.getStatusSnapshot() - expect(row && agentVerdictDisplayMark(row)).toBe('failed') + expect(row && agentVerdictDisplayMark(row)).toBe('interrupted') const [settled] = [ ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() ] expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( - 'Worked for 1s' + 'Interrupted after 1s' ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 916654cac648..c151e3b3abd0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -1,7 +1,7 @@ // A Stop that took effect still decides the turn it named after Orca restarts before the turn's end -// was written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N" -// with the muted mark, not "Failed". A turn nobody stopped, one a Stop never named, and one a -// turnless Stop was still settling when Orca died, still read as the news they are. +// was written: the relaunch's settle reads the Stop's event, so the turn records the person's +// cancellation. A turn nobody stopped, one a Stop never named, and one a turnless Stop was still +// settling when Orca died record no cancellation, and the chat says why each one stopped. import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' @@ -178,8 +178,8 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) - // The notice row is the one explanation; the turn bar reads like a finished turn. - it('reads Worked for N, marked failed, when nobody stopped it', async () => { + // Interrupted, as a Stop is; the notice row is the one explanation, which a Stop never gets. + it('reads Interrupted after N, marked interrupted, with a notice, when nobody stopped it', async () => { await runningTurn(CODEX_TURN) await restartAndSettle() @@ -187,8 +187,8 @@ describe('a restart between a Stop and its turn end', () => { const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(label).toMatch(/^Worked for /) - expect(mark).toBe('failed') + expect(label).toMatch(/^Interrupted after /) + expect(mark).toBe('interrupted') expect(errorRows).toEqual([ expect.stringContaining('stopped while this response was in progress') ]) @@ -209,7 +209,7 @@ describe('a restart between a Stop and its turn end', () => { // Codex refuses a Stop naming a turn that is no longer its active one ("expected active turn id // X but found Y"), as a turn not running, so the child stays. The Stop names X, so Y's end is // never the person's, by its turn id alone. - it('reads Failed for the turn running when the provider refused a Stop naming the one before it', async () => { + it('records no Stop for the turn running when the provider refused a Stop naming the one before it', async () => { await runningTurn(CODEX_TURN) rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, @@ -250,10 +250,13 @@ describe('a restart between a Stop and its turn end', () => { // Its exit is proven after the Stop, so only the turn the Stop named decides. await restartAndSettle('exit-observed') - const { turn, mark } = settled(NEXT_TURN) + const { turn, mark, errorRows } = settled(NEXT_TURN) expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(mark).toBe('failed') + expect(mark).toBe('interrupted') + expect(errorRows).toEqual([ + expect.stringContaining('stopped while this response was in progress') + ]) }) // Claude's Stop ends its child whatever the interrupt answered. diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts index 42f45f1683d2..549c20d32ec2 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts @@ -52,8 +52,8 @@ describe('ThreadAgentStateIndicator', () => { describe('ThreadAgentStateIndicator for a turn that ended without finishing', () => { it.each([ { outcome: 'cancellation', label: 'Interrupted' }, - // A turn anything but the user cut short is a fault, as a failure is. - { outcome: 'interruption', label: 'Failed' }, + // A crash, quit or restart cut it short: interrupted, as a Stop is, not a fault. + { outcome: 'interruption', label: 'Interrupted' }, { outcome: 'unconfirmed', label: 'Couldn’t confirm' } ] as const)('draws $outcome as $label', ({ outcome, label }) => { const threads = makeThreads( diff --git a/src/renderer/src/components/activity/activity-status-group-header.test.ts b/src/renderer/src/components/activity/activity-status-group-header.test.ts index cbcf468b900d..12e6d27b3a92 100644 --- a/src/renderer/src/components/activity/activity-status-group-header.test.ts +++ b/src/renderer/src/components/activity/activity-status-group-header.test.ts @@ -56,13 +56,12 @@ describe('the status group headers', () => { { newer: 'cancellation', older: 'interruption' }, { newer: 'interruption', older: 'cancellation' } ] as const)( - "never mixes a user's Stop with a crash, whichever is newest ($newer newest)", + "groups a user's Stop with a crash cut under Interrupted, whichever is newest ($newer newest)", ({ newer, older }) => { const groups = statusGroups(newer, older) - // A crash sits with failures; the Stop alone heads Interrupted, below it. + // A crash cut is an interruption, not a failure, so both sit in the one Interrupted group. expect(groups.map((group) => [group.key, group.state, group.label])).toEqual([ - ['failed', 'failed', 'Failed'], ['interrupted', 'interrupted', 'Interrupted'] ]) } diff --git a/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts b/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts index 1765b9426e32..3dca542fe4a0 100644 --- a/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts +++ b/src/renderer/src/components/activity/activity-thread-grouping.status-order.test.ts @@ -77,8 +77,8 @@ describe('status group order', () => { expect(groups.map((group) => group.key)).toEqual(['working', 'done']) }) - it("ranks a user's Stop and a replaced turn below live work, above Done", () => { - for (const outcome of ['cancellation', 'superseded'] as const) { + it("ranks a user's Stop, a replaced turn and a crash cut below live work, above Done", () => { + for (const outcome of ['cancellation', 'superseded', 'interruption'] as const) { const groups = buildActivityThreadGroups( makeStatusThreads([ { paneKey: PANE_KEY, state: 'working', at: 1_000 }, @@ -100,7 +100,7 @@ describe('status group order', () => { makeStatusThreads([ { paneKey: PANE_KEY, state: 'working', at: 5_000 }, { paneKey: PANE_KEY_2, state: 'done', at: 1_000, outcome: 'unconfirmed' }, - { paneKey: PANE_KEY_3, state: 'done', at: 3_000, outcome: 'interruption' } + { paneKey: PANE_KEY_3, state: 'done', at: 3_000, outcome: 'failure' } ]), 'status' ) diff --git a/src/renderer/src/components/activity/activity-thread-presentation.test.ts b/src/renderer/src/components/activity/activity-thread-presentation.test.ts index 6aa3efc1a322..f30acaf3ae54 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.test.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest' -import type { AgentPaneThread } from './activity-thread-types' -import { activityThreadRowCopy } from './activity-thread-presentation' +import type { AgentTurnOutcome } from '../../../../shared/agent-turn-outcome' +import type { ActivityEvent, AgentPaneThread } from './activity-thread-types' +import { activityThreadRowCopy, agentMeta, agentTitle } from './activity-thread-presentation' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { makeRepo, + makeTab, makeTabWithIds, + makeWorkingEntryWithoutHistory, makeWorktree, PANE_KEY } from './ActivityPrototypePage-test-fixtures' @@ -95,3 +98,35 @@ describe('activityThreadRowCopy', () => { expect(copy.statusLine).toBe('') }) }) + +describe('agentTitle and agentMeta for a finished turn', () => { + function doneEvent(outcome: AgentTurnOutcome): ActivityEvent { + return { + id: 'event-1', + state: 'done', + timestamp: 3_000, + observedAt: 3_000, + worktree: makeWorktree(), + repo: makeRepo(), + entry: { + ...makeWorkingEntryWithoutHistory(), + state: 'done', + mainAgent: { state: 'done', outcome, stateStartedAt: 3_000 } + }, + tab: makeTab(), + agentType: 'claude', + agentAlive: false, + unread: false + } + } + + // A crash, quit or restart cut reads as the interruption it is, as a Stop does; only a failure fails. + it.each([ + ['cancellation', 'Agent interrupted', 'Claude interrupted'], + ['interruption', 'Agent interrupted', 'Claude interrupted'], + ['failure', 'Agent failed', 'Claude failed'] + ] as const)('titles a %s as %s', (outcome, title, meta) => { + expect(agentTitle(doneEvent(outcome))).toBe(title) + expect(agentMeta(doneEvent(outcome))).toBe(meta) + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts index 4614b192d1b3..a766e41c947f 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.ts @@ -73,12 +73,11 @@ export function agentTitle(event: ActivityEvent): string { } if (event.state === 'done') { switch (agentMainAgentVerdict(event.entry)) { - // A turn cut short by anything but the user is a fault, as a failure is. case 'failure': - case 'interruption': return 'Agent failed' case 'cancellation': case 'superseded': + case 'interruption': return 'Agent interrupted' case 'unconfirmed': return 'Couldn’t confirm agent finished' @@ -110,10 +109,10 @@ export function agentMeta(event: ActivityEvent): string { if (event.state === 'done') { switch (agentMainAgentVerdict(event.entry)) { case 'failure': - case 'interruption': return `${agent} failed` case 'cancellation': case 'superseded': + case 'interruption': return `${agent} interrupted` case 'unconfirmed': return `${agent} unconfirmed` diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 5ac748027972..ddab1dc5ff43 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -315,14 +315,17 @@ describe('DashboardAgentRow', () => { ) } - it('renders a crash-cut row as failed, with the red dot and no interrupted tag', () => { + // A crash, quit or restart cut it short: drawn as a Stop is, but not credited to the user. + it('renders a crash-cut row as interrupted, with the muted dot and the interrupted tag', () => { const markup = renderEndedRow({ mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 2_000 } }) - expect(markup).toContain('aria-label="Failed"') - expect(markup).toContain('bg-red-500') - expect(markup).not.toContain('>interrupted<') + expect(markup).toContain('aria-label="Interrupted"') + expect(markup).not.toContain('aria-label="Interrupted by user"') + expect(markup).toContain('>interrupted<') + expect(markup).toContain('bg-muted-foreground') + expect(markup).not.toContain('bg-red-500') expect(markup).not.toContain('lucide-circle-check') }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx index acce19bcf523..40e04f63e2cb 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx @@ -112,8 +112,8 @@ function session(): NativeChatLiveSession { } describe('NativeChatMessageList folded turn headers', () => { - // A crash-cut turn reads like a finished one; the chat's notice row is what says it stopped. - it('says a crash-cut turn worked like a finished one and a stopped turn was interrupted', () => { + // A crash-cut turn reads interrupted, as a stopped one does; the chat's notice row says why. + it('says a crash-cut turn and a stopped turn were interrupted, and a finished one worked', () => { render( { .map((button) => [button.textContent, button.getAttribute('aria-expanded')]) expect(headers).toEqual([ ['Worked for 12s', 'false'], - ['Worked for 12s', 'false'], + ['Interrupted after 12s', 'false'], ['Interrupted after 12s', 'false'] ]) // The turn's detail stays inside the fold. diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 90b3274c4094..e3cb7424bd43 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -204,9 +204,9 @@ describe('selectWorktreeAgentActivitySummary', () => { it.each([ ['success', { hasLiveDone: true }], ['failure', { hasFailed: true, hasLiveDone: false }], - // A user's Stop reads interrupted; a turn anything else cut short is a fault, like a failure. + // A user's Stop and a crash, quit or restart cut alike read interrupted; only a failure is red. ['cancellation', { hasInterrupted: true, hasLiveDone: false }], - ['interruption', { hasFailed: true, hasInterrupted: false, hasLiveDone: false }], + ['interruption', { hasFailed: false, hasInterrupted: true, hasLiveDone: false }], ['unconfirmed', { hasUnconfirmed: true, hasLiveDone: false }] ] as const)('flags a %s outcome apart from the others', (outcome, flags) => { vi.spyOn(Date, 'now').mockReturnValue(2_000) @@ -329,6 +329,38 @@ describe('selectWorktreeAgentActivitySummary', () => { } } + // A cut is the chat's state, not a live report: no freshness window clears it, and a departed + // agent's cut stays interrupted rather than reading done. + it('keeps a crash-cut chat interrupted past the freshness window and after it departs', () => { + const cut = { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } as const + const cutEntry = makeAgentStatusEntry({ paneKey: failedKey, state: 'done', mainAgent: cut }) + vi.spyOn(Date, 'now').mockReturnValue(1_000 + AGENT_STATUS_STALE_AFTER_MS + 60_000) + const stale = selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { [worktreeId]: [liveTab] }, + agentStatusEpoch: epoch++, + agentStatusByPaneKey: { [failedKey]: cutEntry }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + worktreeId + ) + expect(stale).toMatchObject({ hasInterrupted: true, hasFailed: false }) + + const departed = cardFor( + {}, + { + 'tab-2:0': { + ...retainedFailure['tab-2:0'], + entry: makeAgentStatusEntry({ paneKey: 'tab-2:0', state: 'done', mainAgent: cut }) + } + } + ) + expect(departed.summary).toMatchObject({ hasInterrupted: true, hasRetainedDone: false }) + expect(departed.status).toBe('interrupted') + }) + it('reads a retained failure as failed once nothing else is live, not done', () => { const { summary, status } = cardFor({}, retainedFailure) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 795d297344c4..a3bc37e8b1b7 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -1,5 +1,5 @@ import type { AppState } from '@/store' -import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { isAgentStatusShownOnDot } from '@/lib/pane-agent-evidence' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { mergeAgentStatusOrchestration, @@ -19,7 +19,7 @@ export type WorktreeAgentActivitySummary = { hasLiveMonitoring: boolean /** A fresh failed main agent, also while its subagents run; kept apart from clean done. */ hasFailed: boolean - /** Fresh interrupted completion, kept separate from clean done outcomes. */ + /** An interrupted turn (fresh, cut short, or retained), kept separate from clean done outcomes. */ hasInterrupted: boolean /** A fresh end Orca cannot prove, likewise never a clean done. */ hasUnconfirmed: boolean @@ -133,7 +133,7 @@ function getWorktreeAgentActivitySummaries( addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) continue } - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + if (!isAgentStatusShownOnDot(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { // Why: staleness ends this row's authority but not the pane's identity — see // `stalePaneIdsByTabId`. Dropping both let Orca's self-authored permission title outlive // the row it came from and pin the card to a question nobody was asking. @@ -157,11 +157,17 @@ function getWorktreeAgentActivitySummaries( for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) { const summary = summaryForWorktree(retained.worktreeId) - // Why: a failed agent is retained so its failure stays visible, not so it reads done. - if (agentVerdictDisplayMark(retained.entry) === 'failed') { - summary.hasRetainedFailed = true - } else { - summary.hasRetainedDone = true + // Why: a failed or cut-short agent is retained so that stays visible, not so it reads done. + switch (agentVerdictDisplayMark(retained.entry)) { + case 'failed': + summary.hasRetainedFailed = true + break + case 'interrupted': + summary.hasInterrupted = true + break + case 'unconfirmed': + case null: + summary.hasRetainedDone = true } const paneIdentity = parseAgentStatusPaneIdentity(retained.entry?.paneKey) if (paneIdentity) { diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts index 9f070c355bdd..55ad2e8421e4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.test.ts @@ -118,7 +118,8 @@ describe('worktree card agent summary', () => { expect(eligible).toContain('data-slot="tooltip-trigger"') }) - it("lists a crash-cut turn as failed and a user's Stop as interrupted, before clean completions", () => { + // A crash cut reads interrupted like a Stop, but only a Stop is credited to the user. + it("lists a crash-cut turn and a user's Stop as interrupted, before clean completions", () => { const done = monitoringAgent() done.state = 'done' done.entry.state = 'done' @@ -138,8 +139,8 @@ describe('worktree card agent summary', () => { entry: { ...done.entry, paneKey: 'tab-1:leaf-3', interrupted: true } } - expect(getAgentDotState(interrupted)).toBe('failed') - expect(getCompactAgentSecondary(interrupted, 0)).toBe('Failed') + expect(getAgentDotState(interrupted)).toBe('interrupted') + expect(getCompactAgentSecondary(interrupted, 0)).toBe('Interrupted') expect(getAgentDotState(stopped)).toBe('interrupted') expect(getCompactAgentSecondary(stopped, 0)).toBe('Interrupted by user') const replaced = { @@ -152,7 +153,7 @@ describe('worktree card agent summary', () => { expect(getAgentDotState(replaced)).toBe('interrupted') expect(getCompactAgentSecondary(replaced, 0)).toBe('Interrupted') expect(summarizeAgents([done, interrupted, stopped], 'Agents')).toBe( - 'Agents: 1 failed, 1 interrupted, 1 done' + 'Agents: 2 interrupted, 1 done' ) }) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 7a18342bb4ba..a66942a3b60f 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -155,9 +155,9 @@ describe('resolveTerminalTabActivityStatus', () => { it.each([ ['success', 'done'], ['failure', 'failed'], - // A user's Stop reads interrupted; a turn anything else cut short is a fault, like a failure. + // A user's Stop and a crash, quit or restart cut alike read interrupted; only a failure is red. ['cancellation', 'interrupted'], - ['interruption', 'failed'], + ['interruption', 'interrupted'], ['unconfirmed', 'unconfirmed'] ] as const)('reports a %s done as %s, matching the worktree card', (outcome, status) => { const ended = entry(FIRST_LEAF_ID, 'done', { @@ -233,6 +233,30 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('interrupted') }) + // The cut is the chat's state, not a live report, so the freshness window never clears it; a + // Stop's row, which a live report carries, still ages out. + it('keeps a crash-cut tab interrupted past the freshness window, until the chat changes', () => { + const cut = entry(FIRST_LEAF_ID, 'done', { + updatedAt: 0, + mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 0 } + }) + const stopped = entry(FIRST_LEAF_ID, 'done', { + updatedAt: 0, + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 0 } + }) + vi.setSystemTime(AGENT_STATUS_STALE_AFTER_MS + 60_000) + const status = (row: typeof cut) => + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [row.paneKey]: row }, + ptyIdsByTabId: LIVE_PTY + }) + expect(status(cut)).toBe('interrupted') + expect(status(stopped)).not.toBe('interrupted') + // The next turn replaces it. + expect(status(entry(FIRST_LEAF_ID, 'working', { updatedAt: Date.now() }))).toBe('working') + }) + it('falls back to a live working title when hook status is stale', () => { const stale = entry(FIRST_LEAF_ID, 'done', { updatedAt: 0 }) vi.setSystemTime(31 * 60 * 1000) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 9c92d90c884d..9cd70cf1a6f1 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -2,7 +2,7 @@ import { readAgentAttentionUnreadReason, type ReadableAgentAttentionUnread } from '@/attention/agent-attention-contract' -import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { isAgentStatusShownOnDot } from '@/lib/pane-agent-evidence' import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' import { AGENT_STATUS_STALE_AFTER_MS, @@ -71,7 +71,7 @@ function getTerminalTabActivityFlags( } // Why: stale hook entries (>30m) are not authority; a slept/abandoned pane // must not keep a tab spinning. Same freshness gate as the sidebar. - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + if (!isAgentStatusShownOnDot(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { // Stale identity suppresses Orca's one-shot permission label without suppressing native titles. getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId).stalePaneIds.add( identity.paneId diff --git a/src/renderer/src/lib/agent-verdict-status-line.ts b/src/renderer/src/lib/agent-verdict-status-line.ts index 2c7aecefd4a5..a83fa6877e91 100644 --- a/src/renderer/src/lib/agent-verdict-status-line.ts +++ b/src/renderer/src/lib/agent-verdict-status-line.ts @@ -5,8 +5,8 @@ import { } from '../../../shared/agent-main-agent-verdict' /** The line an agent row shows in place of its preview once its verdict marks it. A user's Stop - * says so; a turn a newer request replaced reads plainly interrupted, since no one is named; a - * turn cut off by a crash reads failed, as a failure does. */ + * says so; a turn a newer request replaced, or one a crash, quit or restart cut off, reads plainly + * interrupted, since no one is named. */ export function agentVerdictStatusLine(entry: AgentMainAgentVerdictSource): string | null { switch (agentVerdictDisplayMark(entry)) { case 'failed': diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 9c9ed4d4b805..9906bbd5eb30 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -1,6 +1,7 @@ import type { AgentStatus } from '../../../shared/agent-detection' import { detectAgentStatusFromTitle, getAgentLabel } from '../../../shared/agent-detection' import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import { agentMainAgentVerdict } from '../../../shared/agent-main-agent-verdict' import type { TerminalAgent } from '../../../shared/terminal-agent' import { AGENT_STATUS_STALE_AFTER_MS, @@ -35,6 +36,25 @@ export function isExplicitAgentStatusFresh( ) } +/** + * Whether a row still sets its pane's status dot: while its evidence is fresh, and, for a turn a + * crash, quit or restart cut short, until the chat's next turn replaces it. That cut is the chat's + * state read from its journal, not a live report that goes quiet, so no timer may clear it. + */ +export function isAgentStatusShownOnDot( + entry: Parameters[0] & + Pick, + now: number, + staleAfterMs: number +): boolean { + return ( + isExplicitAgentStatusFresh(entry, now, staleAfterMs) || + (entry.restoredUnconfirmed !== true && + entry.state === 'done' && + agentMainAgentVerdict(entry) === 'interruption') + ) +} + /** * Title-only activity classification for consumers whose product rule really * is "what does this title say" (timer seeding, ready-wait polling, sort-epoch diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts index f9c091901047..408efccd9969 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -158,12 +158,13 @@ describe('resolveRecentWorkspaceTabStatus', () => { expect(resolveRecentWorkspaceTabStatus(row('split'), paneSources, NOW)).toBe('permission') }) - it('surfaces a turn a crash cut short as failed', () => { - const cut = entry('cut', 'done', NOW - 1_000, { - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: NOW - 1_000 } - }) - - expect(resolveRecentWorkspaceTabStatus(row('cut'), sources([cut]), NOW)).toBe('failed') + it('reads a turn a crash cut short as interrupted, as a Stop does, past the freshness window', () => { + for (const at of [NOW - 1_000, NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000]) { + const cut = entry('cut', 'done', at, { + mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: at } + }) + expect(resolveRecentWorkspaceTabStatus(row('cut'), sources([cut]), NOW)).toBe('interrupted') + } }) it("reads a user's Stop as interrupted though attention demotes it, whether recorded or an old host's flag", () => { diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts index 124ab24535c8..223665494f15 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -8,7 +8,7 @@ import { } from '@/components/sidebar/smart-attention' import { tabHasLivePty } from './tab-has-live-pty' import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' -import { isExplicitAgentStatusFresh } from './pane-agent-evidence' +import { isAgentStatusShownOnDot } from './pane-agent-evidence' import type { WorktreeStatus } from './worktree-status' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -80,8 +80,7 @@ export function resolveRecentWorkspaceTabStatus( } const verdicts = new Set( panes.flatMap((pane) => - pane.kind === 'hook' && - isExplicitAgentStatusFresh(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS) + pane.kind === 'hook' && isAgentStatusShownOnDot(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS) ? [agentVerdictDisplayMark(pane.entry)] : [] ) diff --git a/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts b/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts index 71a1400409e0..c475ee1526e0 100644 --- a/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts +++ b/src/renderer/src/runtime/paired-agent-row-turn-outcome-arms.test.ts @@ -129,7 +129,7 @@ describe('a paired client mirroring a host verdict arm', () => { }) it.each([ - ['interruption', 'failed'], + ['interruption', 'interrupted'], ['unconfirmed', 'unconfirmed'] ] as const)('reads a mirrored %s as its own mark', (outcome, mark) => { const [row] = mirroredRows(outcome) diff --git a/src/shared/agent-main-agent-verdict.test.ts b/src/shared/agent-main-agent-verdict.test.ts index d2b1bd185f59..11c189bf1c38 100644 --- a/src/shared/agent-main-agent-verdict.test.ts +++ b/src/shared/agent-main-agent-verdict.test.ts @@ -51,11 +51,11 @@ describe('agentMainAgentVerdict', () => { verdict === 'cancellation' || verdict === 'superseded' ) expect(agentVerdictDisplayMark(row), label).toBe( - verdict === 'failure' || verdict === 'interruption' + verdict === 'failure' ? 'failed' : row.state !== 'done' ? null - : verdict === 'cancellation' || verdict === 'superseded' + : verdict === 'cancellation' || verdict === 'superseded' || verdict === 'interruption' ? 'interrupted' : verdict === 'unconfirmed' ? 'unconfirmed' @@ -64,17 +64,18 @@ describe('agentMainAgentVerdict', () => { } }) - it.each(['failure', 'interruption'] as const)( - 'reads a main agent whose turn ended in %s while its subagent still works as failed', - (outcome) => { - const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } } - expect(agentMainAgentVerdict(row)).toBe(outcome) - expect(agentVerdictDisplayMark(row)).toBe('failed') + it('reads a main agent whose turn failed while its subagent still works as failed', () => { + const row = { + state: 'working' as const, + mainAgent: { state: 'done' as const, outcome: 'failure' as const } } - ) + expect(agentMainAgentVerdict(row)).toBe('failure') + expect(agentVerdictDisplayMark(row)).toBe('failed') + }) - it('keeps a success or a stop with live subagent work reading working', () => { - for (const outcome of ['success', 'cancellation'] as const) { + // A crash cut is an interruption, not a fault, so live child work still reads working, as a Stop does. + it('keeps a success, a stop or a crash cut with live subagent work reading working', () => { + for (const outcome of ['success', 'cancellation', 'interruption'] as const) { const row = { state: 'working' as const, mainAgent: { state: 'done' as const, outcome } } expect(agentVerdictDisplayMark(row)).toBeNull() } @@ -97,14 +98,15 @@ describe('agentMainAgentVerdict', () => { expect(agentMainAgentVerdict({ state: 'done', interrupted: true })).toBe('cancellation') }) - it("marks a done row by its verdict: a user's Stop reads interrupted, any other cut failed", () => { + it('marks a done row by its verdict: a Stop or a crash cut reads interrupted, a failure failed', () => { for (const [outcome, mark] of [ ['success', null], ['failure', 'failed'], ['cancellation', 'interrupted'], // A newer request replaced it: not news, and no one to name. ['superseded', 'interrupted'], - ['interruption', 'failed'], + // A crash, quit or restart cut it short: interrupted until the chat's state changes. + ['interruption', 'interrupted'], ['unconfirmed', 'unconfirmed'] ] as const) { const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } } @@ -114,9 +116,9 @@ describe('agentMainAgentVerdict', () => { expect(agentVerdictDisplayMark({ state: 'done', interrupted: true })).toBe('interrupted') }) - it('reads a crash-cut turn as failed and an unproven end as unconfirmed, neither a stop', () => { + it('reads a crash-cut turn as interrupted and an unproven end as unconfirmed, neither on purpose', () => { for (const [outcome, mark] of [ - ['interruption', 'failed'], + ['interruption', 'interrupted'], ['unconfirmed', 'unconfirmed'] ] as const) { const row = { state: 'done' as const, mainAgent: { state: 'done' as const, outcome } } diff --git a/src/shared/agent-main-agent-verdict.ts b/src/shared/agent-main-agent-verdict.ts index 929b1d2748cf..7108b85f7306 100644 --- a/src/shared/agent-main-agent-verdict.ts +++ b/src/shared/agent-main-agent-verdict.ts @@ -47,21 +47,21 @@ export function agentMainAgentVerdict(row: AgentMainAgentVerdictSource): AgentTu } /** - * What the verdict marks on the agent's own display. A fault, whether the turn failed or something - * other than the user cut it short, reads failed and outranks every combined state: it is news the - * user must see even while subagents still run. A user's Stop, or a turn a newer request replaced, - * reads interrupted, and an unproven end unconfirmed, only on a row that is itself done, so live - * child work still reads working. + * What the verdict marks on the agent's own display. A failure reads failed and outranks every + * combined state: it is news the user must see even while subagents still run. A turn that ended + * early reads interrupted, whoever ended it (a user's Stop, a newer request, or a crash, quit or + * restart), and an unproven end unconfirmed, only on a row that is itself done, so live child work + * still reads working. */ export function agentVerdictDisplayMark( row: AgentMainAgentVerdictSource ): 'failed' | 'interrupted' | 'unconfirmed' | null { switch (agentMainAgentVerdict(row)) { case 'failure': - case 'interruption': return 'failed' case 'cancellation': case 'superseded': + case 'interruption': return row.state === 'done' ? 'interrupted' : null case 'unconfirmed': return row.state === 'done' ? 'unconfirmed' : null diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 08557aef9a0a..99a754bb7df1 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -43,8 +43,8 @@ describe('describeNativeChatTurnStatus', () => { ['cancellation', 'interruptedAfter'], ['superseded', 'interruptedAfter'], ['failure', 'failedAfter'], - // A crash or restart cut it off: the turn reads finished and the chat's notice row says why. - ['interruption', 'workedFor'], + // A crash or restart cut it off: it reads interrupted, as a Stop does; the notice row says why. + ['interruption', 'interruptedAfter'], ['success', 'workedFor'], ['unconfirmed', 'workedFor'], [undefined, 'workedFor'] @@ -110,14 +110,14 @@ describe('formatNativeChatTurnStatusLabel', () => { }) // The phone's turn bar renders through this function, so it reads the same as desktop. - it('reads a crash-cut turn as worked and a Stop as interrupted in English', () => { + it('reads a crash-cut turn and a Stop alike as interrupted in English', () => { expect( formatNativeChatTurnStatusLabel({ workedSeconds: 222, elapsedSeconds: 0, verdict: 'interruption' }) - ).toBe('Worked for 3m 42s') + ).toBe('Interrupted after 3m 42s') expect( formatNativeChatTurnStatusLabel({ workedSeconds: 222, diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index 446c489c350c..ce0d24f23203 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -41,9 +41,9 @@ export function describeNativeChatTurnStatus({ }: { workedSeconds?: number | null elapsedSeconds: number - /** How the settled turn ended: a user's Stop or a newer request's replacement reads interrupted, - * and a failure reads failed. A turn cut short when the agent stopped without anyone asking (a - * crash, a quit, an eviction) reads like a finished one: the chat's notice row says it stopped. */ + /** How the settled turn ended: a user's Stop, a newer request's replacement, or a cut when the + * agent stopped without anyone asking (a crash, a quit, an eviction) reads interrupted, and a + * failure reads failed. Only the cut gets the chat's notice row saying why it stopped. */ verdict?: AgentTurnOutcome }): { key: 'workingFor' | 'workedFor' | 'interruptedAfter' | 'failedAfter' @@ -60,11 +60,11 @@ function settledTurnStatusKey( switch (verdict) { case 'cancellation': case 'superseded': + case 'interruption': return 'interruptedAfter' case 'failure': return 'failedAfter' case 'success': - case 'interruption': case 'unconfirmed': case undefined: return 'workedFor' diff --git a/tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts index ac53bc112b9c..df9f5e782d72 100644 --- a/tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts @@ -212,7 +212,8 @@ describe('cross-version worktree ps verdict', () => { 'failed-done': 'failed', 'failed-working': 'failed', stopped: 'interrupted', - 'crash-cut': 'failed', + // A crash cut reads interrupted, as a Stop does; the host publishes the same row as before. + 'crash-cut': 'interrupted', unproven: 'unconfirmed', replaced: 'interrupted' }) From cd82addf0d153564f5ffb2190ec02652ab9e389b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:05:45 -0700 Subject: [PATCH 02/24] fix(native-chat): say a cut response was interrupted, muted, without blaming the agent After an Orca quit or restart, the cut turn's notice read "Codex stopped while this response was in progress" in error red: it blamed the agent for something Orca did, and its red contradicted the turn's Interrupted status. A reader cannot tell a quit, an eviction or a restart apart (the journal publishes no cause), so the notice now says "This response was interrupted. You can continue in this conversation." as a muted host status line, translated on desktop. The host writes the row a reopen leaves for an owner found dead the same way, and readers re-present such rows already stored in the old words. The agent's own unexpected-exit row is unchanged. --- ...red-agent-session.cut-turn-notice.test.tsx | 5 +- .../use-mobile-structured-agent-session.ts | 9 +-- ...ured-agent-session-attach-orchestration.ts | 4 +- ...ctured-agent-session-close-verdict.test.ts | 14 ++-- ...uctured-agent-session-conversation-open.ts | 4 +- ...tured-agent-session-crash-turn-end.test.ts | 22 +++--- ...gent-session-dead-generation-settlement.ts | 16 ++--- ...gent-session-provider-child-record.test.ts | 4 +- ...ssion-restart-interruption-test-harness.ts | 12 ++-- ...ured-agent-session-stale-turn-open.test.ts | 6 +- ...d-agent-session-stale-turn-verdict.test.ts | 4 +- ...d-agent-session-stop-event-restart.test.ts | 26 +++---- ...red-agent-session-surface-lifetime.test.ts | 9 +-- ...t-session-wedged-profile-migration.test.ts | 6 +- .../native-chat/NativeChatNoticeRow.test.tsx | 20 +++++- .../native-chat/NativeChatNoticeRow.tsx | 5 ++ ...red-agent-session.cut-turn-notice.test.tsx | 5 +- .../use-structured-agent-session.ts | 8 +-- .../src/i18n/en-runtime-required.json | 3 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/agent-session-host-status-rows.ts | 11 ++- .../native-chat-cut-turn-notice.test.ts | 65 +++++++++++++---- src/shared/native-chat-cut-turn-notice.ts | 72 +++++++++---------- 28 files changed, 200 insertions(+), 148 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx index 37a29458c07d..c7f39a899513 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -120,8 +120,9 @@ describe('useMobileStructuredAgentSession transcript', () => { role: 'system', blocks: [ { - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - tone: 'error' + text: 'This response was interrupted. You can continue in this conversation.', + presentation: 'response-interrupted', + tone: 'notice' } ] }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 7bec053c9d67..19e8e2b81b15 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -3,7 +3,6 @@ import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-c import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { @@ -162,13 +161,7 @@ export function useMobileStructuredAgentSession(args: { // What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no // row gets. - const transcriptItems = useMemo( - () => - withNativeChatCutTurnNotices(state.items, { - agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] - }), - [agent, state.items] - ) + const transcriptItems = useMemo(() => withNativeChatCutTurnNotices(state.items), [state.items]) const messages = useMemo( // Off: the phone hands a rejected message back to its composer, so a row would show it twice. () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index d8f89065de4f..33b8ebf90e7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -22,7 +22,6 @@ import { } from './structured-agent-session-launch-env' import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionProviderChild, @@ -184,8 +183,7 @@ async function runAttach( sessionId, fence, acquisitionGeneration, - deathEvidence: priorDeathEvidence, - failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord) + deathEvidence: priorDeathEvidence }) } await bindAndDrain(eventSink, attached.journal, fence, (activity) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 30085cfca15b..e961aa86506f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -123,17 +123,17 @@ async function settledTurn() { const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] const label = settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }).key - // Every error row a reader's transcript shows, the cut turn's derived notice included. - const notices = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + // Every row a reader's transcript shows about a stop, the cut turn's derived notice included. + const notices = withNativeChatCutTurnNotices(items).flatMap((item) => + item.body.kind === 'status' && (item.body.tone === 'error' || item.body.tone === 'notice') + ? [`${item.body.tone}: ${item.body.text}`] + : [] ) return { turn, settled, label, notices } } -/** A turn nobody stopped reads interrupted, with exactly one row saying why it stopped. */ -const ONE_NOTICE = [ - 'Codex stopped while this response was in progress. You can continue in this conversation.' -] +/** A turn nobody stopped reads interrupted, with exactly one muted row saying so, blaming no one. */ +const ONE_NOTICE = ['notice: This response was interrupted. You can continue in this conversation.'] function lastSummary(statuses: AgentSessionStatusEvent[]) { const last = statuses.at(-1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index dc3d4fc42ae7..5d9ad7490d61 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -20,7 +20,6 @@ import { } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -139,8 +138,7 @@ async function settleGoneGeneration( sessionId: record.sessionId, fence: record.lease.runtimeFence, acquisitionGeneration: null, - deathEvidence: record.lease.deathEvidence ?? null, - failureTextContext: structuredAgentSessionFailureWordsContext(record) + deathEvidence: record.lease.deathEvidence ?? null }) } catch (error) { // Best effort: the next open or acquire re-derives it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 52dc50ded545..09d2867fb21e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -224,17 +224,23 @@ describe('a turn a crash cut short mid-tool', () => { await host.restoreReadableSessions() const { items } = await host.journalSnapshot(SESSION) - // As a reader's transcript shows it: the stored row is the explanation, so none is derived. - const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap( - (item) => (item.body.kind === 'status' ? [item.body] : []) + // As a reader's transcript shows it: the stored row is the explanation, so none is derived. Orca + // went away, not the agent, so the row blames no one and is muted, not an error. + const statusRows = withNativeChatCutTurnNotices(items).flatMap((item) => + item.body.kind === 'status' ? [item.body] : [] ) expect(statusRows).toEqual([ - expect.objectContaining({ - text: 'Claude stopped while this response was in progress. You can continue in this conversation.', - failure: expect.objectContaining({ kind: 'providerExited' }), - tone: 'error' - }) + { + kind: 'status', + text: 'This response was interrupted. You can continue in this conversation.', + presentation: 'response-interrupted', + tone: 'notice' + } ]) + // The host writes it so, for a reader older than this rule too. + expect(items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : []))).toEqual( + statusRows + ) const [timing] = selectStructuredAgentTurnTimings(items).values() expect( describeNativeChatTurnStatus({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 639b34cb7955..452c58233e3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -5,6 +5,7 @@ import { } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' +import { agentSessionResponseInterruptedBody } from '../../../shared/agent-session-host-status-rows' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -216,8 +217,6 @@ export async function settleStaleStructuredAgentSessionState(input: { fence: number acquisitionGeneration: string | null deathEvidence: AgentSessionDeathEvidence | null - /** Who the exit row names. */ - failureTextContext?: AgentSessionFailureWordsContext }): Promise { const { journal } = input const items = journal.snapshot().items @@ -264,16 +263,9 @@ export async function settleStaleStructuredAgentSessionState(input: { provider: 'orca', clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` }, - // The death evidence is Orca's log text, never a sentence for a person: the row says only - // that the provider stopped. - body: { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { - ...input.failureTextContext, - surface: 'row' - }), - tone: 'error' - }, + // The death evidence is Orca's log text, never a sentence for a person. The owner that went + // away was Orca, not the agent, so the row says only that the response was interrupted. + body: agentSessionResponseInterruptedBody(), turnScope: runningRootTurnScope(items) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 6d42155fa386..03d4d0526e46 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -361,9 +361,7 @@ describe('settling an earlier child before the next one takes its message', () = ) expect( items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain( - 'Codex stopped while this response was in progress. You can continue in this conversation.' - ) + ).toContain('This response was interrupted. You can continue in this conversation.') // Handed over at the new child's fence, which the attach reserved after settling. const newFence = store.getRecord(SESSION)!.lease.runtimeFence expect(newFence).toBeGreaterThan(releasedFence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 59d478cbb40a..dad32a7d2095 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -186,11 +186,7 @@ export async function statusNotes(host: StructuredAgentSessionHost) { /** The notes a reader's transcript shows, the cut turn's derived notice included. */ export async function readerNotes(host: StructuredAgentSessionHost) { - return notesOf( - withNativeChatCutTurnNotices((await host.journalSnapshot(SESSION)).items, { - agentName: 'Codex' - }) - ) + return notesOf(withNativeChatCutTurnNotices((await host.journalSnapshot(SESSION)).items)) } function notesOf(items: readonly AgentJournalRenderItem[]) { @@ -199,10 +195,10 @@ function notesOf(items: readonly AgentJournalRenderItem[]) { ) } -/** The one row the quit's cut turn reads with when nothing else explains it. */ +/** The one row the quit's cut turn reads with when nothing else explains it: muted, blaming no one. */ export const QUIT_CUT_NOTICE = { - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - tone: 'error' + text: 'This response was interrupted. You can continue in this conversation.', + tone: 'notice' } /** A continuation the host refuses because the user's own message was accepted first: another diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts index 204da81a2e6c..989896c8db3c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -79,7 +79,7 @@ describe('a turn its gone agent left running', () => { expect(await workingTurnState()).toBe('interrupted') const { items } = await rig.host.journalSnapshot(SESSION) expect(JSON.stringify(items)).toContain( - 'Codex stopped while this response was in progress. You can continue in this conversation.' + 'This response was interrupted. You can continue in this conversation.' ) expect(rig.adapter.acquire).toHaveBeenCalledOnce() }) @@ -95,9 +95,9 @@ describe('a turn its gone agent left running', () => { await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) expect(await workingTurnState()).toBe('interrupted') - // The death evidence is Orca's log text: the row says only that the provider stopped. + // The death evidence is Orca's log text: the row says only that the response was interrupted. expect(JSON.stringify(reader.events)).toContain( - 'Codex stopped while this response was in progress. You can continue in this conversation.' + 'This response was interrupted. You can continue in this conversation.' ) expect(rig.adapter.acquire).toHaveBeenCalledOnce() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 8a9deaad48c0..4f1482f40da9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -468,9 +468,7 @@ describe('stale session state on a cold acquire', () => { // The probe's detail is Orca's, so the row carries none. expect( items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toEqual([ - 'The agent stopped while this response was in progress. You can continue in this conversation.' - ]) + ).toEqual(['This response was interrupted. You can continue in this conversation.']) } finally { await journals.closeAll() await rm(root, { recursive: true, force: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index c151e3b3abd0..5b66ab8f8f04 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -113,8 +113,10 @@ function settled(turnId?: string) { mark: verdict ? agentVerdictDisplayMark({ state: 'done', mainAgent: { state: 'done', outcome: verdict } }) : null, - errorRows: items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + stopRows: items.flatMap((item) => + item.body.kind === 'status' && (item.body.tone === 'error' || item.body.tone === 'notice') + ? [item.body.text] + : [] ) } } @@ -130,12 +132,12 @@ describe('a restart between a Stop and its turn end', () => { await restartAndSettle() - const { turn, label, mark, errorRows } = settled() + const { turn, label, mark, stopRows } = settled() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) expect(label).toMatch(/^Interrupted after /) expect(mark).toBe('interrupted') // A live Stop writes no row saying the provider stopped; nor does its relaunch. - expect(errorRows).toEqual([]) + expect(stopRows).toEqual([]) }) // A Stop naming its turn, as the phone sends one: its event names the turn, which is what a @@ -170,12 +172,12 @@ describe('a restart between a Stop and its turn end', () => { await restartAndSettle() - const { turn, label, mark, errorRows } = settled() + const { turn, label, mark, stopRows } = settled() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) expect(label).toMatch(/^Interrupted after /) expect(mark).toBe('interrupted') // A live Stop writes no row saying the provider stopped; nor does its relaunch. - expect(errorRows).toEqual([]) + expect(stopRows).toEqual([]) }) // Interrupted, as a Stop is; the notice row is the one explanation, which a Stop never gets. @@ -184,13 +186,13 @@ describe('a restart between a Stop and its turn end', () => { await restartAndSettle() - const { turn, label, mark, errorRows } = settled() + const { turn, label, mark, stopRows } = settled() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') expect(label).toMatch(/^Interrupted after /) expect(mark).toBe('interrupted') - expect(errorRows).toEqual([ - expect.stringContaining('stopped while this response was in progress') + expect(stopRows).toEqual([ + 'This response was interrupted. You can continue in this conversation.' ]) }) @@ -250,12 +252,12 @@ describe('a restart between a Stop and its turn end', () => { // Its exit is proven after the Stop, so only the turn the Stop named decides. await restartAndSettle('exit-observed') - const { turn, mark, errorRows } = settled(NEXT_TURN) + const { turn, mark, stopRows } = settled(NEXT_TURN) expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') expect(mark).toBe('interrupted') - expect(errorRows).toEqual([ - expect.stringContaining('stopped while this response was in progress') + expect(stopRows).toEqual([ + 'This response was interrupted. You can continue in this conversation.' ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 5a1ad69f6130..519f214f34c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -825,7 +825,8 @@ describe('an unexpected provider exit', () => { ) expect(acquire).toHaveBeenCalledTimes(2) // The new child's acquire settled the turn from the release's evidence: ended at the exit's - // receipt. The evidence is Orca's log text, so the row says only that the provider stopped. + // receipt. The evidence is Orca's log text, so the row says only that the response was + // interrupted, muted. const history = await host.history({ sessionId: SESSION, direction: 'tail' }) const items = history.ok ? history.page.items : [] expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( @@ -834,9 +835,9 @@ describe('an unexpected provider exit', () => { const statuses = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) expect(statuses).toContainEqual({ kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: { kind: 'providerExited' }, - tone: 'error' + text: 'This response was interrupted. You can continue in this conversation.', + presentation: 'response-interrupted', + tone: 'notice' }) expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 08532f63c1fd..e2381f0e1a09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -346,11 +346,7 @@ describe('already-wedged profiles become usable on load', () => { .snapshot() .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) expect(statusRows).toEqual( - remote - ? [] - : [ - 'Codex stopped while this response was in progress. You can continue in this conversation.' - ] + remote ? [] : ['This response was interrupted. You can continue in this conversation.'] ) // What the sidebar reads: every status this restart published says the chat is not working. expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index b9e453573b82..960bab423223 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -94,12 +94,30 @@ describe('notice rows', () => { // The host's text is only for a client that can't word the row itself. it.each([ ['history-repaired', "Part of this chat's history couldn't be loaded."], - ['history-item-too-large', 'This part of the chat was too large to show.'] + ['history-item-too-large', 'This part of the chat was too large to show.'], + [ + 'response-interrupted', + 'This response was interrupted. You can continue in this conversation.' + ] ])('words a %s row itself, as a muted status line', (presentation, words) => { renderStatus({ kind: 'status', text: 'Words an older host wrote', presentation }) expect(screen.getByText(words)).toHaveClass('text-muted-foreground', 'text-sm') expect(screen.queryByText('Words an older host wrote')).toBeNull() }) + // An interruption is not an error: its notice tone, for a client that can't name the row, stays muted. + it('draws an interrupted response muted, never in the error colour', () => { + renderStatus({ + kind: 'status', + text: 'Words an older host wrote', + presentation: 'response-interrupted', + tone: 'notice' + }) + const line = screen.getByText( + 'This response was interrupted. You can continue in this conversation.' + ) + expect(line).toHaveClass('text-muted-foreground') + expect(line.closest('.text-destructive')).toBeNull() + }) it('renders future presentation and tone values as untinted text', () => { renderStatus({ kind: 'status', diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index b63eb73abd17..a0864ebaadc3 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -23,6 +23,11 @@ const HOST_STATUS_WORDS: Record string translate( 'components.native-chat.notices.historyItemTooLarge', HOST_STATUS_COPY['history-item-too-large'] + ), + 'response-interrupted': () => + translate( + 'components.native-chat.notices.responseInterrupted', + HOST_STATUS_COPY['response-interrupted'] ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx index fcb474f2acc6..4c00a7b4ad54 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx @@ -86,8 +86,9 @@ describe('useStructuredAgentSession transcript', () => { role: 'system', blocks: [ { - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - tone: 'error' + text: 'This response was interrupted. You can continue in this conversation.', + presentation: 'response-interrupted', + tone: 'notice' } ] }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 6d73bc3ce050..6628dc128531 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -40,7 +40,6 @@ import { structuredAgentSessionStartFailureFacts } from './structured-agent-sess import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -172,11 +171,8 @@ export function useStructuredAgentSession(args: { ) // What the transcript reads: the journal plus the one notice a cut turn with no row gets. const transcriptItems = useMemo( - () => - withNativeChatCutTurnNotices(transportState.journalItems, { - agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] - }), - [agent, transportState.journalItems] + () => withNativeChatCutTurnNotices(transportState.journalItems), + [transportState.journalItems] ) const messages = useStructuredAgentSessionMessages( transcriptItems, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 3cc1ed29e599..519120e519ae 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2807,7 +2807,8 @@ }, "notices": { "historyItemTooLarge": "This part of the chat was too large to show.", - "historyRepaired": "Part of this chat's history couldn't be loaded." + "historyRepaired": "Part of this chat's history couldn't be loaded.", + "responseInterrupted": "This response was interrupted. You can continue in this conversation." }, "question": { "other": "Other…" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25087a71ea2b..954d180cc777 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17852,7 +17852,8 @@ "details": "Details", "plan": "Plan", "historyRepaired": "Part of this chat's history couldn't be loaded.", - "historyItemTooLarge": "This part of the chat was too large to show." + "historyItemTooLarge": "This part of the chat was too large to show.", + "responseInterrupted": "This response was interrupted. You can continue in this conversation." }, "composer": { "imageUnsupported": "Image paste is not supported for this agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ec2d25bbb344..0f2514d8fd5b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17932,7 +17932,8 @@ "details": "Detalles", "plan": "Plan", "historyRepaired": "No se pudo cargar parte del historial de este chat.", - "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla." + "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla.", + "responseInterrupted": "Esta respuesta se interrumpió. Puedes continuar en esta conversación." }, "providerFrame": { "byteLength": "{{value0}} bytes" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 333d821d346e..da095a2e8b28 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17933,7 +17933,8 @@ "details": "Détails", "plan": "Plan", "historyRepaired": "Une partie de l'historique de ce chat n'a pas pu être chargée.", - "historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée." + "historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée.", + "responseInterrupted": "Cette réponse a été interrompue. Vous pouvez continuer dans cette conversation." }, "providerFrame": { "byteLength": "{{value0}} octets" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5d1678b46d0c..fd75a1798ed0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17869,7 +17869,8 @@ "details": "詳細", "plan": "プラン", "historyRepaired": "このチャットの履歴の一部を読み込めませんでした。", - "historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。" + "historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。", + "responseInterrupted": "この応答は中断されました。この会話の続きから再開できます。" }, "providerFrame": { "byteLength": "{{value0}}バイト" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5cd0371e7bec..3204ba0f7430 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17869,7 +17869,8 @@ "details": "세부", "plan": "계획", "historyRepaired": "이 채팅 기록의 일부를 불러올 수 없습니다.", - "historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다." + "historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다.", + "responseInterrupted": "이 응답이 중단되었습니다. 이 대화에서 계속할 수 있습니다." }, "providerFrame": { "byteLength": "{{value0}}바이트" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3ce2dbdb7ba2..47eb08039c89 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17834,7 +17834,8 @@ "details": "细节", "plan": "计划", "historyRepaired": "此聊天的部分历史记录无法加载。", - "historyItemTooLarge": "聊天的这一部分过大,无法显示。" + "historyItemTooLarge": "聊天的这一部分过大,无法显示。", + "responseInterrupted": "此回复已中断。你可以在此对话中继续。" }, "providerFrame": { "byteLength": "{{value0}} 字节" diff --git a/src/shared/agent-session-host-status-rows.ts b/src/shared/agent-session-host-status-rows.ts index 880eb105ed0b..30c77ae325c6 100644 --- a/src/shared/agent-session-host-status-rows.ts +++ b/src/shared/agent-session-host-status-rows.ts @@ -7,7 +7,10 @@ export const AGENT_SESSION_HOST_STATUS_COPY = { /** A repair skipped rows it could not read. */ 'history-repaired': "Part of this chat's history couldn't be loaded.", /** In place of an item too large for any history page. */ - 'history-item-too-large': 'This part of the chat was too large to show.' + 'history-item-too-large': 'This part of the chat was too large to show.', + /** A turn cut short with nobody asking (a crash, quit, restart or eviction), worded for every cause + * because a reader can't tell them apart. */ + 'response-interrupted': 'This response was interrupted. You can continue in this conversation.' } as const export type AgentSessionHostStatusPresentation = keyof typeof AGENT_SESSION_HOST_STATUS_COPY @@ -18,6 +21,12 @@ export function agentSessionHostStatusBody( return { kind: 'status', text: AGENT_SESSION_HOST_STATUS_COPY[presentation], presentation } } +/** The row that explains a turn cut short with nobody asking. Muted, not an error: the turn was + * interrupted, and `notice` is the tone a client that can't name the presentation draws it in. */ +export function agentSessionResponseInterruptedBody(): AgentJournalPlainStatusItem { + return { ...agentSessionHostStatusBody('response-interrupted'), tone: 'notice' } +} + export function isAgentSessionHostStatusPresentation( presentation: string | undefined ): presentation is AgentSessionHostStatusPresentation { diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 09fc37ba5773..de4af3feef10 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -21,7 +21,9 @@ import { import { structuredAgentSessionStartFailureRowIdentity } from './structured-agent-session-start-failure-row-key' const THREAD: AgentJournalTurnScope = { kind: 'thread' } -const NOTICE = +const NOTICE = 'This response was interrupted. You can continue in this conversation.' +/** What an older host wrote for an owner found dead, and what the exit row still says. */ +const AGENT_STOPPED = 'Codex stopped while this response was in progress. You can continue in this conversation.' let sequence = 0 @@ -107,7 +109,7 @@ const restartNote = (text: string, tone?: 'error' | 'warning') => ) function notices(items: readonly AgentJournalRenderItem[]) { - return withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((entry, index) => + return withNativeChatCutTurnNotices(items).flatMap((entry, index) => entry.itemId.includes('cut-turn-notice') ? [ { @@ -121,7 +123,7 @@ function notices(items: readonly AgentJournalRenderItem[]) { } describe('withNativeChatCutTurnNotices', () => { - it('gives a cut turn no row explains one notice in the exit row words, after its last row', () => { + it('gives a cut turn no row explains one muted notice that blames no one, after its last row', () => { const items = [ user('u1'), turn('t1', 'u1', CUT), @@ -130,11 +132,17 @@ describe('withNativeChatCutTurnNotices', () => { turn('t2', 'u2', { state: 'completed', outcome: 'success' }) ] - const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const derived = withNativeChatCutTurnNotices(items) expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) const notice = derived[3]! - expect(notice.body).toMatchObject({ tone: 'error', failure: { kind: 'providerExited' } }) + // Muted, not an error: the turn was interrupted, and nothing here says who interrupted it. + expect(notice.body).toEqual({ + kind: 'status', + text: NOTICE, + presentation: 'response-interrupted', + tone: 'notice' + }) // Placed by journal position too, so any reader that sorts keeps it under the turn. expect(notice.sequence).toBe(items[2]!.sequence) expect(notice.sequenceIndex).toBeGreaterThan(0) @@ -152,19 +160,43 @@ describe('withNativeChatCutTurnNotices', () => { reply('a1', inTurn('t1')), exitRow(id, scope) ] - expect(withNativeChatCutTurnNotices(items), id).toBe(items) + expect(notices(items), id).toEqual([]) } }) + // The owner that went away was Orca, so an older host's red "the agent stopped" blamed the agent. + it("words an owner's proven death as the notice does, and leaves the agent's own exit row as written", () => { + const owner = exitRow('stale-session:s:death-3-2000', inTurn('t1')) + const exit = exitRow('provider-exit:s:3:gen', inTurn('t2')) + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + owner, + user('u2'), + turn('t2', 'u2', CUT), + exit + ] + + const derived = withNativeChatCutTurnNotices(items) + + expect(derived).toHaveLength(items.length) + expect(derived[2]).toEqual({ + ...owner, + body: { kind: 'status', text: NOTICE, presentation: 'response-interrupted', tone: 'notice' } + }) + expect(derived[5]).toBe(exit) + expect(withNativeChatCutTurnNotices(items)[2]).toBe(derived[2]) + }) + // A host from before failure facts wrote the same rows with only their words. it("counts an older host's exit row by its writer, with no exit fact", () => { for (const id of ['provider-exit:s:3:gen', 'stale-session:s:death-3-2000']) { const items = [ user('u1'), turn('t1', 'u1', CUT), - hostRow(id, { kind: 'status', text: NOTICE, tone: 'error' }, inTurn('t1')) + hostRow(id, { kind: 'status', text: AGENT_STOPPED, tone: 'error' }, inTurn('t1')) ] - expect(withNativeChatCutTurnNotices(items), id).toBe(items) + expect(notices(items), id).toEqual([]) } }) @@ -319,7 +351,7 @@ describe('withNativeChatCutTurnNotices', () => { user('u2'), exitRow('stale-session:s:death-3-2000', THREAD) ] - expect(withNativeChatCutTurnNotices(items)).toBe(items) + expect(notices(items)).toEqual([]) }) it("does not take a failed start's row as the explanation of an earlier cut", () => { @@ -338,7 +370,7 @@ describe('withNativeChatCutTurnNotices', () => { it('places the notice in its turn, for desktop and phone alike', () => { const items = [user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1')), user('u2')] - const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const derived = withNativeChatCutTurnNotices(items) const messages = projectStructuredAgentSessionMessages(derived, [], [], { rejectedInPlace: true }) @@ -346,7 +378,12 @@ describe('withNativeChatCutTurnNotices', () => { const notice = messages.findIndex((message) => message.id.includes('cut-turn-notice')) expect(messages[notice]?.blocks).toEqual([ - expect.objectContaining({ type: 'text', text: NOTICE, tone: 'error' }) + expect.objectContaining({ + type: 'text', + text: NOTICE, + presentation: 'response-interrupted', + tone: 'notice' + }) ]) expect(turnKeys[notice]).toBe('u1') }) @@ -370,12 +407,12 @@ describe('withNativeChatCutTurnNotices', () => { payloadFingerprint: 'fp-u2', dispatchState: 'rejected', providerItemId: null, - reason: NOTICE, + reason: AGENT_STOPPED, submittedAt: 50, resolvedAt: 60 } ] - const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const derived = withNativeChatCutTurnNotices(items) const messages = projectStructuredAgentSessionMessages(derived, [], submissions, { rejectedInPlace: true }) @@ -394,7 +431,7 @@ describe('withNativeChatCutTurnNotices', () => { user('u2', null) ] - const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const derived = withNativeChatCutTurnNotices(items) expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: undefined }]) expect(hostStatesTurnScopes(derived)).toBe(false) diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index ddf486363084..4afed7abf3a2 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -3,12 +3,10 @@ // is the durable fact, so a journal written before this rule, a quit that died mid-settle and any // future stop cause all read the same. A row the host already wrote about the stop stays the // explanation, so nothing is said twice. Shared by desktop and mobile, whose transcripts must agree. +// The journal names no cause a reader can see, so the words fit every cause and blame no one. -import { agentSessionFailureFact, readAgentSessionFailureFact } from './agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from './agent-session-failure-words' +import { readAgentSessionFailureFact } from './agent-session-failure' +import { agentSessionResponseInterruptedBody } from './agent-session-host-status-rows' import { agentJournalItemKey, parseAgentJournalItemKey } from './agent-session-journal-item-key' import { isRootAgentJournalItem } from './agent-session-journal-producer' import type { AgentJournalRenderItem } from './agent-session-journal-types' @@ -24,8 +22,6 @@ import { hostStatesTurnScopes } from './native-chat-turn-membership' const CUT_TURN_NOTICE_ROW = 'cut-turn-notice:' -type CutTurnNoticeContext = Pick - function rootTurnVerdict( item: AgentJournalRenderItem ): ReturnType | 'none' { @@ -139,26 +135,16 @@ function lastRowOfTurn( const noticeCache = new WeakMap< AgentJournalRenderItem, - { - after: AgentJournalRenderItem - statesScopes: boolean - agentName: string | undefined - notice: AgentJournalRenderItem - } + { after: AgentJournalRenderItem; statesScopes: boolean; notice: AgentJournalRenderItem } >() function cutTurnNotice( turnItem: AgentJournalRenderItem, after: AgentJournalRenderItem, - statesScopes: boolean, - context: CutTurnNoticeContext + statesScopes: boolean ): AgentJournalRenderItem { const cached = noticeCache.get(turnItem) - if ( - cached?.after === after && - cached.statesScopes === statesScopes && - cached.agentName === context.agentName - ) { + if (cached?.after === after && cached.statesScopes === statesScopes) { return cached.notice } const notice: AgentJournalRenderItem = { @@ -167,14 +153,7 @@ function cutTurnNotice( clientMessageId: `${CUT_TURN_NOTICE_ROW}${turnItem.itemId}` }), revision: 0, - body: { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { - ...context, - surface: 'row' - }), - tone: 'error' - }, + body: agentSessionResponseInterruptedBody(), // Just after the turn's last row and before anything the journal wrote next. sequence: after.sequence, sequenceIndex: (after.sequenceIndex ?? 0) + 0.5, @@ -182,31 +161,52 @@ function cutTurnNotice( // A scope on a journal that states none would change how every other row is placed. ...(statesScopes ? { turnScope: { kind: 'turn' as const, turnItemId: turnItem.itemId } } : {}) } - noticeCache.set(turnItem, { after, statesScopes, agentName: context.agentName, notice }) + noticeCache.set(turnItem, { after, statesScopes, notice }) return notice } +const ownerDeathRowCache = new WeakMap() + +/** A reopen's row about an owner found dead, in the notice's words: that owner was Orca, so an older + * host's "the agent stopped" blamed the agent, and in error red. */ +function ownerDeathRowAsInterruption(item: AgentJournalRenderItem): AgentJournalRenderItem { + if (stopExplanation(item) !== 'owner-death') { + return item + } + const cached = ownerDeathRowCache.get(item) + if (cached) { + return cached + } + const reworded = { ...item, body: agentSessionResponseInterruptedBody() } + ownerDeathRowCache.set(item, reworded) + return reworded +} + /** * The journal as the transcript reads it: each root turn cut short with nobody asking, and no row - * saying so, gets one notice in the words of the provider-exit row, right after the turn's last row. - * Returns `items` itself when no turn needs one. + * saying so, gets one muted notice right after the turn's last row, and a reopen's row about an + * owner found dead says the same. Returns `items` itself when neither applies. */ export function withNativeChatCutTurnNotices( - items: readonly AgentJournalRenderItem[], - context: CutTurnNoticeContext = {} + items: readonly AgentJournalRenderItem[] ): readonly AgentJournalRenderItem[] { const explained = explainedCutTurns(items) const statesScopes = hostStatesTurnScopes(items) const noticesAfter = new Map() + let rewords = false items.forEach((item, index) => { if (isCutRootTurn(item) && !explained.has(item.itemId)) { const last = lastRowOfTurn(items, index, statesScopes) - const notice = cutTurnNotice(item, items[last]!, statesScopes, context) + const notice = cutTurnNotice(item, items[last]!, statesScopes) noticesAfter.set(last, [...(noticesAfter.get(last) ?? []), notice]) } + rewords ||= ownerDeathRowAsInterruption(item) !== item }) - if (noticesAfter.size === 0) { + if (noticesAfter.size === 0 && !rewords) { return items } - return items.flatMap((item, index) => [item, ...(noticesAfter.get(index) ?? [])]) + return items.flatMap((item, index) => [ + ownerDeathRowAsInterruption(item), + ...(noticesAfter.get(index) ?? []) + ]) } From 43cc46bd2c4d7195610cc39ad1e14797242bdc78 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:12:24 -0700 Subject: [PATCH 03/24] test(native-chat): the agent's own exit row stays the one red row a reader sees --- .../claude-structured-exit-mid-response.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/runtime/claude-structured-exit-mid-response.test.ts b/src/main/runtime/claude-structured-exit-mid-response.test.ts index fd30df7dad0e..009f6cb95205 100644 --- a/src/main/runtime/claude-structured-exit-mid-response.test.ts +++ b/src/main/runtime/claude-structured-exit-mid-response.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' @@ -68,5 +69,18 @@ describe('a started Claude CLI that exits while a response is in progress', () = } ]) ) + // The agent really died on its own, so the reader shows this one row, in its error tone, and + // derives no interrupted notice beside it. + const { items } = await host.journalSnapshot(SESSION) + expect( + withNativeChatCutTurnNotices(items).flatMap((item) => + item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] + ) + ).toEqual([ + { + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + } + ]) }) }) From e6c2097370d4a659bb68ca87f99f74240db5434a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:53:32 -0700 Subject: [PATCH 04/24] fix(native-chat): a turn the agent's own exit cut short reads failed, as its exit row does The agent exiting on its own mid-turn ends the turn interrupted with no verdict, as a quit does, so every status surface and the turn bar read it Interrupted under a red " stopped" row. One shared reader now derives the verdict: a cut that the agent's own exit row explains reads `failure`. The host's status projection and the turn bar both use it, so the published row and the chat agree. Nothing is stored, so no completion event (and no notification) is sent for it. --- ...agent-session-recovered-turn-clock.test.ts | 44 +++++- .../native-chat-cut-turn-explanation.test.ts | 102 ++++++++++++++ .../native-chat-cut-turn-explanation.ts | 131 ++++++++++++++++++ src/shared/native-chat-cut-turn-notice.ts | 103 ++------------ src/shared/native-chat-turn-status.ts | 6 +- ...structured-agent-session-latest-request.ts | 7 + .../structured-agent-session-projection.ts | 10 +- .../structured-agent-session-turn-timing.ts | 16 ++- 8 files changed, 302 insertions(+), 117 deletions(-) create mode 100644 src/shared/native-chat-cut-turn-explanation.test.ts create mode 100644 src/shared/native-chat-cut-turn-explanation.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index ce133785e51d..f4d0ce4197fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -142,17 +142,17 @@ describe('a turn recovery settled after its host went away', () => { it.each([ ['an unverifiable end', { state: 'unverifiable' } as const, 'unconfirmed', 'unconfirmed'], [ - 'an exit observed before the restart', + 'the agent exiting on its own before the restart', { state: 'interrupted', completedAt: EXIT_OBSERVED }, - 'interruption', - // A turn a crash, quit or restart cut short reads interrupted, as a Stop does. - 'interrupted' + // Its own exit row explains the cut, so it reads as that agent's failure, never announced. + 'failure', + 'failed' ] ] satisfies [ string, StructuredAgentSessionTurnVerdict, - 'unconfirmed' | 'interruption', - 'unconfirmed' | 'interrupted' + 'unconfirmed' | 'failure', + 'unconfirmed' | 'failed' ][])( 'is done as of the recovery with the end the host observed, never a success: %s', async (_label, verdict, outcome, mark) => { @@ -188,8 +188,20 @@ describe('a turn recovery settled after its host went away', () => { it.each([ [ 'a restart', + // The reopen finds the old agent process gone. (journal: AgentSessionJournal) => - settleDeadGeneration(journal, { state: 'interrupted', completedAt: EXIT_OBSERVED }) + settleStaleStructuredAgentSessionState({ + journal, + sessionId: SESSION, + fence: 2, + acquisitionGeneration: 'generation-2', + deathEvidence: { + kind: 'exit-observed', + detail: 'gone', + observedAt: EXIT_OBSERVED, + ownerFence: 1 + } + }) ], [ 'quitting Orca', @@ -226,6 +238,24 @@ describe('a turn recovery settled after its host went away', () => { } ) + // The agent's own exit is its failure: the tab and the turn bar agree with its red exit row. + it('reads Failed after N, marked failed, for a turn the agent cut by exiting on its own', async () => { + const session = await sessionWithRunningTurn() + session.recoverAt(RECOVERED) + await settleDeadGeneration(session.journal, { state: 'interrupted', completedAt: EXIT_OBSERVED }) + session.publish() + + const [row] = session.server.getStatusSnapshot() + expect(row && agentVerdictDisplayMark(row)).toBe('failed') + const [settled] = [ + ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() + ] + expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( + 'Failed after 1s' + ) + expect(session.completionEvents).toEqual([]) + }) + it('is dated the same way when a new provider child finds the turn still running', async () => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) diff --git a/src/shared/native-chat-cut-turn-explanation.test.ts b/src/shared/native-chat-cut-turn-explanation.test.ts new file mode 100644 index 000000000000..985300e1a419 --- /dev/null +++ b/src/shared/native-chat-cut-turn-explanation.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { agentSessionResponseInterruptedBody } from './agent-session-host-status-rows' +import { agentJournalItemKey } from './agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { structuredAgentTurnVerdictReader } from './native-chat-cut-turn-explanation' +import { projectStructuredAgentSessionStatusState } from './structured-agent-session-projection' +import { selectStructuredAgentSettledTurns } from './structured-agent-session-turn-timing' + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const IN_CUT: AgentJournalTurnScope = { kind: 'turn', turnItemId: 'orca:t1' } +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} + +const user = (id: string) => + item(`orca:${id}`, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }) +const cutTurn = (id: string, userId: string) => + item(`orca:${id}`, { + kind: 'turn', + turnId: id, + userItemId: `orca:${userId}`, + state: 'interrupted', + startedAt: 1_000, + completedAt: 13_000 + }) +const hostRow = (clientMessageId: string, body: AgentJournalItemBody, scope = THREAD) => + item(agentJournalItemKey({ provider: 'orca', clientMessageId }), body, scope) +/** The row the agent's own exit leaves: its fact, in error red. */ +const exitRow = (scope = THREAD) => + hostRow( + 'provider-exit:s:1:g', + { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + agentName: 'Codex', + surface: 'row' + }), + tone: 'error' + }, + scope + ) +/** The row a reopen leaves for an old agent process it found gone. */ +const ownerDeathRow = () => + hostRow('stale-session:s:death-1-2000', agentSessionResponseInterruptedBody(), IN_CUT) + +/** The cut turn's verdict as the turn bar, the status row and the reader each take it. */ +function readings(items: readonly AgentJournalRenderItem[]) { + const turn = items.find((entry) => entry.itemId === 'orca:t1')! + const { summary, latestRequest } = projectStructuredAgentSessionStatusState(items) + return { + reader: structuredAgentTurnVerdictReader(items)(turn), + turnBar: selectStructuredAgentSettledTurns(items).get('orca:u1')?.verdict, + status: summary.turnOutcome, + // Never stored: the completion feed announces only a recorded outcome. + recorded: latestRequest?.outcome + } +} + +describe('structuredAgentTurnVerdictReader', () => { + it.each([ + ['scoped to the turn', () => [user('u1'), cutTurn('t1', 'u1'), exitRow(IN_CUT)]], + ['about the conversation, right after the cut', () => [user('u1'), cutTurn('t1', 'u1'), exitRow()]], + [ + 'beside a reopen row for the same death', + () => [user('u1'), cutTurn('t1', 'u1'), ownerDeathRow(), exitRow(IN_CUT)] + ] + ])("reads a cut the agent's own exit explains as its failure, row %s", (_label, journal) => { + expect(readings(journal())).toEqual({ + reader: 'failure', + turnBar: 'failure', + status: 'failure', + recorded: null + }) + }) + + it.each([ + ['no row says why (a quit, an eviction)', () => [user('u1'), cutTurn('t1', 'u1')]], + ['a reopen found the old agent gone', () => [user('u1'), cutTurn('t1', 'u1'), ownerDeathRow()]], + [ + 'the exit row answers a message sent after the cut', + () => [user('u1'), cutTurn('t1', 'u1'), user('u2'), exitRow()] + ] + ])('keeps a cut interrupted when %s', (_label, journal) => { + const read = readings(journal()) + expect(read.reader).toBe('interruption') + expect(read.turnBar).toBe('interruption') + expect(read.recorded).toBeNull() + }) +}) diff --git a/src/shared/native-chat-cut-turn-explanation.ts b/src/shared/native-chat-cut-turn-explanation.ts new file mode 100644 index 000000000000..0db53935bb3a --- /dev/null +++ b/src/shared/native-chat-cut-turn-explanation.ts @@ -0,0 +1,131 @@ +// What a row already says about a root turn cut short with nobody asking. Read by the transcript's +// cut notice (an explained cut gets none) and by every reader of a turn's verdict (a cut the agent's +// own exit explains is that agent's failure), so the two always agree on the cause. + +import { readAgentSessionFailureFact } from './agent-session-failure' +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' +import { agentTurnVerdict, type AgentTurnOutcome } from './agent-turn-outcome' +import { + PROVIDER_EXIT_ROW_PREFIX, + RESTART_CONTINUATION_ROW_PREFIX, + STALE_SESSION_ROW_PREFIX +} from './agent-session-stop-row-identity' +import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key' + +/** `exit`: the agent stopped on its own. `owner-death`: a reopen proved the old agent process gone, + * which is about the cut whatever was sent since. `not-continued`: a resume after a restart did not + * carry the chat on. */ +export type CutTurnStopExplanation = 'exit' | 'owner-death' | 'not-continued' + +function rootTurnVerdict(item: AgentJournalRenderItem): AgentTurnOutcome | null | 'none' { + const turn = readAgentJournalTurn(item.body) + return turn && isRootAgentJournalItem(item) + ? agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) + : 'none' +} + +/** Root turns that ended interrupted with no verdict, so nobody asked for the stop. */ +export function isCutRootTurn(item: AgentJournalRenderItem): boolean { + return rootTurnVerdict(item) === 'interruption' +} + +/** What a row says about a stop, matched by what it states or who wrote it, never its tone alone: + * an agent's own error row in the turn (a denied permission, a refusal) says nothing about the stop. + * A restart note marks a continuation that did not go on with a tone ('error' refused or not + * connected, 'warning' unconfirmed); one that went on has none. A failed start's row is about a + * start. */ +export function cutTurnStopExplanation( + item: AgentJournalRenderItem +): CutTurnStopExplanation | null { + if ( + item.body.kind !== 'status' || + readAgentJournalTurn(item.body) || + isStructuredAgentSessionStartFailureRow(item.itemId) + ) { + return null + } + const identity = parseAgentJournalItemKey(item.itemId) + const clientMessageId = identity?.provider === 'orca' ? identity.clientMessageId : '' + if (clientMessageId.startsWith(STALE_SESSION_ROW_PREFIX)) { + return 'owner-death' + } + if ( + readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited' || + clientMessageId.startsWith(PROVIDER_EXIT_ROW_PREFIX) + ) { + return 'exit' + } + const { tone } = item.body + return clientMessageId.startsWith(RESTART_CONTINUATION_ROW_PREFIX) && + (tone === 'error' || tone === 'warning') + ? 'not-continued' + : null +} + +/** + * The cut turns some row already explains, and how: one scoped to the turn, or one about the + * conversation (or from a host that states no scope) that follows the cut turn closely enough to be + * about it. An exit row is about the cut only with no message sent since, which a later start would + * be answering. An owner's proven death, and a restart note that follows the continuation's own + * message, are about the cut until another turn begins. The agent's own exit outranks any other + * explanation of the same turn: it is the one row that names a cause. + */ +export function explainedCutTurns( + items: readonly AgentJournalRenderItem[] +): ReadonlyMap { + const explained = new Map() + let cutNoSendSince: string | null = null + let cutNoTurnSince: string | null = null + for (const item of items) { + const verdict = rootTurnVerdict(item) + if (verdict !== 'none') { + cutNoSendSince = cutNoTurnSince = verdict === 'interruption' ? item.itemId : null + continue + } + if (item.body.kind === 'message' && item.body.role === 'user' && isRootAgentJournalItem(item)) { + cutNoSendSince = null + continue + } + const explanation = cutTurnStopExplanation(item) + if (explanation === null) { + continue + } + const scope = item.turnScope + const turnItemId = + scope?.kind === 'turn' + ? scope.turnItemId + : explanation === 'exit' + ? cutNoSendSince + : cutNoTurnSince + if (turnItemId !== null && explained.get(turnItemId) !== 'exit') { + explained.set(turnItemId, explanation) + } + } + return explained +} + +/** + * Reads each turn's verdict as every surface shows it: the turn record's, except that a cut the + * agent's own exit explains is that agent's failure, as its red exit row says. Derived, never + * stored, so old and new journals read alike and no completion event is announced for it. An exit + * whose settle failed is written later by the reopen as an owner's death, whose evidence cannot tell + * the agent's own exit from a quit's close, so that one stays a plain interruption. + */ +export function structuredAgentTurnVerdictReader( + items: readonly AgentJournalRenderItem[] +): (item: AgentJournalRenderItem) => AgentTurnOutcome | null { + let explained: ReadonlyMap | undefined + return (item) => { + const turn = readAgentJournalTurn(item.body) + const verdict = + turn && agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) + if (verdict !== 'interruption' || !isRootAgentJournalItem(item)) { + return verdict ?? null + } + explained ??= explainedCutTurns(items) + return explained.get(item.itemId) === 'exit' ? 'failure' : verdict + } +} diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index 4afed7abf3a2..c090f7eeba90 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -5,107 +5,20 @@ // explanation, so nothing is said twice. Shared by desktop and mobile, whose transcripts must agree. // The journal names no cause a reader can see, so the words fit every cause and blame no one. -import { readAgentSessionFailureFact } from './agent-session-failure' import { agentSessionResponseInterruptedBody } from './agent-session-host-status-rows' -import { agentJournalItemKey, parseAgentJournalItemKey } from './agent-session-journal-item-key' -import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { agentJournalItemKey } from './agent-session-journal-item-key' import type { AgentJournalRenderItem } from './agent-session-journal-types' -import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' -import { agentTurnVerdict } from './agent-turn-outcome' +import { readAgentJournalTurn } from './agent-session-turn-record' +import { isRootAgentJournalItem } from './agent-session-journal-producer' import { - PROVIDER_EXIT_ROW_PREFIX, - RESTART_CONTINUATION_ROW_PREFIX, - STALE_SESSION_ROW_PREFIX -} from './agent-session-stop-row-identity' -import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key' + cutTurnStopExplanation, + explainedCutTurns, + isCutRootTurn +} from './native-chat-cut-turn-explanation' import { hostStatesTurnScopes } from './native-chat-turn-membership' const CUT_TURN_NOTICE_ROW = 'cut-turn-notice:' -function rootTurnVerdict( - item: AgentJournalRenderItem -): ReturnType | 'none' { - const turn = readAgentJournalTurn(item.body) - return turn && isRootAgentJournalItem(item) - ? agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) - : 'none' -} - -/** Root turns that ended interrupted with no verdict, so nobody asked for the stop. */ -function isCutRootTurn(item: AgentJournalRenderItem): boolean { - return rootTurnVerdict(item) === 'interruption' -} - -/** What a row says about a stop, matched by what it states or who wrote it, never its tone alone: - * an agent's own error row in the turn (a denied permission, a refusal) says nothing about the stop. - * `exit`: the agent stopped. `owner-death`: a reopen proved the old agent dead, which is about the - * cut whatever was sent since. `not-continued`: a resume after a restart did not carry the chat on, - * which the restart note marks with a tone ('error' refused or not connected, 'warning' unconfirmed); - * a continuation that went on writes its note with none. A failed start's row is about a start. */ -function stopExplanation( - item: AgentJournalRenderItem -): 'exit' | 'owner-death' | 'not-continued' | null { - if ( - item.body.kind !== 'status' || - readAgentJournalTurn(item.body) || - isStructuredAgentSessionStartFailureRow(item.itemId) - ) { - return null - } - const identity = parseAgentJournalItemKey(item.itemId) - const clientMessageId = identity?.provider === 'orca' ? identity.clientMessageId : '' - if (clientMessageId.startsWith(STALE_SESSION_ROW_PREFIX)) { - return 'owner-death' - } - if ( - readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited' || - clientMessageId.startsWith(PROVIDER_EXIT_ROW_PREFIX) - ) { - return 'exit' - } - const { tone } = item.body - return clientMessageId.startsWith(RESTART_CONTINUATION_ROW_PREFIX) && - (tone === 'error' || tone === 'warning') - ? 'not-continued' - : null -} - -/** - * The cut turns some row already explains: one scoped to the turn, or one about the conversation - * (or from a host that states no scope) that follows the cut turn closely enough to be about it. - * An exit row is about the cut only with no message sent since, which a later start would be - * answering. An owner's proven death, and a restart note that follows the continuation's own - * message, are about the cut until another turn begins. - */ -function explainedCutTurns(items: readonly AgentJournalRenderItem[]): Set { - const explained = new Set() - let cutNoSendSince: string | null = null - let cutNoTurnSince: string | null = null - for (const item of items) { - const verdict = rootTurnVerdict(item) - if (verdict !== 'none') { - cutNoSendSince = cutNoTurnSince = verdict === 'interruption' ? item.itemId : null - continue - } - if (item.body.kind === 'message' && item.body.role === 'user' && isRootAgentJournalItem(item)) { - cutNoSendSince = null - continue - } - const explanation = stopExplanation(item) - if (explanation === null) { - continue - } - const scope = item.turnScope - const preceding = explanation === 'exit' ? cutNoSendSince : cutNoTurnSince - if (scope?.kind === 'turn') { - explained.add(scope.turnItemId) - } else if (preceding !== null) { - explained.add(preceding) - } - } - return explained -} - /** The turn's last row, which the notice follows: one scoped to it, or by journal order on a host * that states no scope. A new root turn ends the order-read run. */ function lastRowOfTurn( @@ -170,7 +83,7 @@ const ownerDeathRowCache = new WeakMap AgentTurnOutcome | null ): StructuredAgentTurnTiming | null { const turn = readAgentJournalTurn(item.body) if (!turn) { @@ -59,7 +62,7 @@ function readTiming( durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : undefined - const verdict = agentTurnVerdict({ state, outcome: readAgentJournalTurnOutcome(turn) }) + const verdict = verdictOf(item) // A send queued behind the previous turn counts from that turn's end (recorded, else its row's // last host revision), never past this turn's own start: the provider opens it only after. const queuedUntil = @@ -94,6 +97,7 @@ function readStructuredAgentJournalTurns( const anchors = structuredAgentTurnAnchors(items, submissions) const byAnchor = new Map() const byTurnId = new Map() + const verdictOf = structuredAgentTurnVerdictReader(items) let precedingTurnEndedAt: number | undefined for (const item of items) { const anchor = anchors.get(item.itemId) @@ -101,7 +105,7 @@ function readStructuredAgentJournalTurns( if (anchor === undefined || !turn) { continue } - const timing = readTiming(item, precedingTurnEndedAt) + const timing = readTiming(item, precedingTurnEndedAt, verdictOf) precedingTurnEndedAt = timing?.completedAt ?? item.observedAt byTurnId.set(turn.turnId, timing) if (timing || turn.state === 'unverifiable') { From edb7327d50a6cb83bf9a2b5cbe45ae08e76b03fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:53:51 -0700 Subject: [PATCH 05/24] test(native-chat): an exit row followed by a reopen row for the same death still reads failed --- src/shared/native-chat-cut-turn-explanation.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/shared/native-chat-cut-turn-explanation.test.ts b/src/shared/native-chat-cut-turn-explanation.test.ts index 985300e1a419..2bad4e98bbae 100644 --- a/src/shared/native-chat-cut-turn-explanation.test.ts +++ b/src/shared/native-chat-cut-turn-explanation.test.ts @@ -74,8 +74,9 @@ describe('structuredAgentTurnVerdictReader', () => { ['scoped to the turn', () => [user('u1'), cutTurn('t1', 'u1'), exitRow(IN_CUT)]], ['about the conversation, right after the cut', () => [user('u1'), cutTurn('t1', 'u1'), exitRow()]], [ - 'beside a reopen row for the same death', - () => [user('u1'), cutTurn('t1', 'u1'), ownerDeathRow(), exitRow(IN_CUT)] + // The exit settle wrote its row but not the turn's end, which the next reopen then wrote. + 'followed by a reopen row for the same death', + () => [user('u1'), cutTurn('t1', 'u1'), exitRow(IN_CUT), ownerDeathRow()] ] ])("reads a cut the agent's own exit explains as its failure, row %s", (_label, journal) => { expect(readings(journal())).toEqual({ From 0b556d245f287247f0e2ec45af0cf00e94032648 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:57:03 -0700 Subject: [PATCH 06/24] fix(native-chat): a chat's settled status stays on the card and tab until the chat changes The card and tab bar drop an agent row 30 minutes after its evidence. That window is for hook reports, which can go quiet; a native chat's row is re-derived from its journal by its host. Only a cut turn was exempt, so a person's Stop, a failure and "Couldn't confirm" on a native chat vanished from the card and tab while the agent rows and the phone kept them. The status bridge now marks its rows as the chat's journal status (renderer-local), and the dot keeps any settled verdict of such a row. A clean done, hook rows and live work still age out, and the liveness check used for subagents, attention and cleanup is unchanged. Drops the Cmd+J exemption and its test: Cmd+J never shows a native chat's status. --- ...tructuredAgentSessionStatusBridge.test.tsx | 21 +++++- .../StructuredAgentSessionStatusBridge.tsx | 1 + .../worktree-agent-activity-summary.test.ts | 64 +++++++++++++------ .../terminal-tab-activity-status.test.ts | 37 ++++++----- .../src/lib/pane-agent-evidence.test.ts | 34 ++++++++++ src/renderer/src/lib/pane-agent-evidence.ts | 17 +++-- .../src/lib/recent-workspace-tab-rows.test.ts | 9 --- .../src/lib/recent-workspace-tab-rows.ts | 5 +- .../src/store/slices/agent-status-contract.ts | 1 + .../slices/agent-status-live-entry-builder.ts | 1 + src/shared/agent-status-types.ts | 3 + 11 files changed, 141 insertions(+), 52 deletions(-) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 1c60187910c2..c674a556c0b1 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -8,7 +8,7 @@ import type { } from '../../../../shared/agent-session-wire' import { buildSubagentChildRows } from '../sidebar/worktree-subagent-child-rows' import { resolveAttention } from '../sidebar/smart-attention' -import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import { isAgentStatusShownOnDot, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Tab } from '../../../../shared/tab-types' import type { AppState } from '@/store/types' @@ -206,6 +206,25 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()[0]).not.toHaveProperty('structuredHostOwned') }) + // The chat's settled verdict is re-derived from its journal, so it is not a report that goes + // quiet: the dot keeps it once the stream is gone, though it is no live evidence. + it("keeps a settled chat's verdict on the dot past the freshness window, once the stream ends", async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + const updatedAt = Date.now() - 30 * 60 * 1000 - 1 + act(() => + feed().emit({ + type: 'status', + session: summary({ status: 'idle', turnOutcome: 'cancellation', updatedAt }) + }) + ) + act(() => feed().emit({ type: 'end' })) + const entry = statuses()[0]! + expect(entry).toMatchObject({ state: 'done', statusSource: 'structured-journal' }) + expect(isExplicitAgentStatusFresh(entry, Date.now(), 30 * 60 * 1000)).toBe(false) + expect(isAgentStatusShownOnDot(entry, Date.now(), 30 * 60 * 1000)).toBe(true) + }) + it('maps each host status onto the sidebar agent state', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index c460ecc48054..74f31d6a2f2e 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -194,6 +194,7 @@ function projectStatus( { ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), terminalResumeEligible: false, + statusSource: 'structured-journal', ...(summary.hostExecutionOwned ? { structuredHostOwned: true as const } : {}) } ) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index e3cb7424bd43..f100b436aa5b 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -329,25 +329,53 @@ describe('selectWorktreeAgentActivitySummary', () => { } } - // A cut is the chat's state, not a live report: no freshness window clears it, and a departed - // agent's cut stays interrupted rather than reading done. - it('keeps a crash-cut chat interrupted past the freshness window and after it departs', () => { - const cut = { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } as const - const cutEntry = makeAgentStatusEntry({ paneKey: failedKey, state: 'done', mainAgent: cut }) - vi.spyOn(Date, 'now').mockReturnValue(1_000 + AGENT_STATUS_STALE_AFTER_MS + 60_000) - const stale = selectWorktreeAgentActivitySummary( - { - tabsByWorktree: { [worktreeId]: [liveTab] }, - agentStatusEpoch: epoch++, - agentStatusByPaneKey: { [failedKey]: cutEntry }, - migrationUnsupportedByPtyId: {}, - runtimeAgentOrchestrationByPaneKey: {}, - retainedAgentsByPaneKey: {} - }, - worktreeId - ) - expect(stale).toMatchObject({ hasInterrupted: true, hasFailed: false }) + // A native chat's settled verdict is its state, re-derived from its journal: no freshness window + // clears it, whoever ended the turn. Hook rows, a clean done and live work still age out. + it("keeps a native chat's settled verdict past the freshness window, and only that", () => { + const ended = ( + outcome: 'interruption' | 'cancellation' | 'failure' | 'unconfirmed' | 'success', + overrides: Partial = {} + ): AgentStatusEntry => ({ + ...makeAgentStatusEntry({ + paneKey: failedKey, + state: 'done', + mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 } + }), + statusSource: 'structured-journal', + ...overrides + }) + const staleCard = (row: AgentStatusEntry) => { + vi.spyOn(Date, 'now').mockReturnValue(1_000 + AGENT_STATUS_STALE_AFTER_MS + 60_000) + return selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { [worktreeId]: [liveTab] }, + agentStatusEpoch: epoch++, + agentStatusByPaneKey: { [failedKey]: row }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + worktreeId + ) + } + expect(staleCard(ended('interruption'))).toMatchObject({ hasInterrupted: true }) + expect(staleCard(ended('cancellation'))).toMatchObject({ hasInterrupted: true }) + expect(staleCard(ended('failure'))).toMatchObject({ hasFailed: true }) + expect(staleCard(ended('unconfirmed'))).toMatchObject({ hasUnconfirmed: true }) + // A clean done is news that ages; a terminal hook row can go quiet; neither is kept. + expect(staleCard(ended('success'))).toMatchObject({ hasLiveDone: false }) + expect(staleCard(ended('cancellation', { statusSource: undefined }))).toMatchObject({ + hasInterrupted: false + }) + // A native chat's stale working row is not a verdict, so it ages out as any report does. + expect( + staleCard(ended('failure', { state: 'working', mainAgent: undefined })) + ).toMatchObject({ hasLiveWorking: false, hasFailed: false }) + }) + + it('reads a retained cut-short agent as interrupted, not done', () => { + const cut = { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } as const const departed = cardFor( {}, { diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index a66942a3b60f..c88c20de3a3a 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -233,28 +233,35 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('interrupted') }) - // The cut is the chat's state, not a live report, so the freshness window never clears it; a - // Stop's row, which a live report carries, still ages out. - it('keeps a crash-cut tab interrupted past the freshness window, until the chat changes', () => { - const cut = entry(FIRST_LEAF_ID, 'done', { - updatedAt: 0, - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: 0 } - }) - const stopped = entry(FIRST_LEAF_ID, 'done', { - updatedAt: 0, - mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 0 } - }) + // A native chat's settled verdict is its state, re-derived from its journal, so the freshness + // window never clears it, whoever ended the turn. A hook row and a clean done still age out. + it("keeps a native chat's settled verdict past the freshness window, until the chat changes", () => { + const ended = ( + outcome: 'interruption' | 'cancellation' | 'failure' | 'success', + overrides: Partial = {} + ) => + entry(FIRST_LEAF_ID, 'done', { + updatedAt: 0, + statusSource: 'structured-journal', + mainAgent: { state: 'done', outcome, stateStartedAt: 0 }, + ...overrides + }) vi.setSystemTime(AGENT_STATUS_STALE_AFTER_MS + 60_000) - const status = (row: typeof cut) => + const status = (row: AgentStatusEntry) => resolveTerminalTabActivityStatus({ tab: TAB, agentStatusByPaneKey: { [row.paneKey]: row }, ptyIdsByTabId: LIVE_PTY }) - expect(status(cut)).toBe('interrupted') - expect(status(stopped)).not.toBe('interrupted') + expect(status(ended('interruption'))).toBe('interrupted') + expect(status(ended('cancellation'))).toBe('interrupted') + expect(status(ended('failure'))).toBe('failed') + expect(status(ended('success'))).not.toBe('done') + expect(status(ended('cancellation', { statusSource: undefined }))).not.toBe('interrupted') // The next turn replaces it. - expect(status(entry(FIRST_LEAF_ID, 'working', { updatedAt: Date.now() }))).toBe('working') + expect( + status(entry(FIRST_LEAF_ID, 'working', { updatedAt: Date.now(), statusSource: 'structured-journal' })) + ).toBe('working') }) it('falls back to a live working title when hook status is stale', () => { diff --git a/src/renderer/src/lib/pane-agent-evidence.test.ts b/src/renderer/src/lib/pane-agent-evidence.test.ts index 0bd618e29406..92d027c39832 100644 --- a/src/renderer/src/lib/pane-agent-evidence.test.ts +++ b/src/renderer/src/lib/pane-agent-evidence.test.ts @@ -5,6 +5,7 @@ import { } from '../../../shared/agent-status-types' import { classifyTitleActivity, + isAgentStatusShownOnDot, isExplicitAgentStatusFresh, resolveCommittedTitleAgentType, resolvePaneAgentActivity, @@ -48,6 +49,39 @@ describe('isExplicitAgentStatusFresh', () => { }) }) +describe('isAgentStatusShownOnDot', () => { + const STALE = NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000 + const settled = (overrides: Partial = {}) => + entry({ + state: 'done', + updatedAt: STALE, + statusSource: 'structured-journal', + mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: STALE }, + ...overrides + }) + + it("keeps a native chat's settled verdict on the dot, without making it live evidence", () => { + const row = settled() + expect(isAgentStatusShownOnDot(row, NOW, AGENT_STATUS_STALE_AFTER_MS)).toBe(true) + // Liveness (child rows under it, attention, cleanup) still reads it stale. + expect(isExplicitAgentStatusFresh(row, NOW, AGENT_STATUS_STALE_AFTER_MS)).toBe(false) + }) + + it.each([ + ['a hook row', { statusSource: undefined }], + ['a clean done', { mainAgent: { state: 'done', outcome: 'success', stateStartedAt: STALE } }], + [ + 'a failed lead still held open by its subagents', + { state: 'working', mainAgent: { state: 'done', outcome: 'failure', stateStartedAt: STALE } } + ], + ['an unconfirmed restore', { restoredUnconfirmed: true }] + ] satisfies [string, Partial][])('lets %s age out', (_label, overrides) => { + expect(isAgentStatusShownOnDot(settled(overrides), NOW, AGENT_STATUS_STALE_AFTER_MS)).toBe( + false + ) + }) +}) + describe('classifyTitleActivity', () => { it('classifies working, permission, idle, and unclassifiable titles', () => { expect(classifyTitleActivity('mimo working')).toBe('working') diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 9906bbd5eb30..4b46fb0515a9 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -1,7 +1,7 @@ import type { AgentStatus } from '../../../shared/agent-detection' import { detectAgentStatusFromTitle, getAgentLabel } from '../../../shared/agent-detection' import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' -import { agentMainAgentVerdict } from '../../../shared/agent-main-agent-verdict' +import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' import type { TerminalAgent } from '../../../shared/terminal-agent' import { AGENT_STATUS_STALE_AFTER_MS, @@ -37,21 +37,24 @@ export function isExplicitAgentStatusFresh( } /** - * Whether a row still sets its pane's status dot: while its evidence is fresh, and, for a turn a - * crash, quit or restart cut short, until the chat's next turn replaces it. That cut is the chat's - * state read from its journal, not a live report that goes quiet, so no timer may clear it. + * Whether a row still sets its pane's status dot: while its evidence is fresh, and, for a native + * chat's settled verdict (Interrupted, Failed, Couldn't confirm), until the chat's next turn + * replaces it. That verdict is the chat's state, re-derived from its journal on every change, not a + * live report that can go quiet, so no timer may clear it. A clean done, and a row still held open + * by work, decay as before. */ export function isAgentStatusShownOnDot( entry: Parameters[0] & - Pick, + Pick, now: number, staleAfterMs: number ): boolean { return ( isExplicitAgentStatusFresh(entry, now, staleAfterMs) || - (entry.restoredUnconfirmed !== true && + (entry.statusSource === 'structured-journal' && + entry.restoredUnconfirmed !== true && entry.state === 'done' && - agentMainAgentVerdict(entry) === 'interruption') + agentVerdictDisplayMark(entry) !== null) ) } diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts index 408efccd9969..1c8d47284a8f 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -158,15 +158,6 @@ describe('resolveRecentWorkspaceTabStatus', () => { expect(resolveRecentWorkspaceTabStatus(row('split'), paneSources, NOW)).toBe('permission') }) - it('reads a turn a crash cut short as interrupted, as a Stop does, past the freshness window', () => { - for (const at of [NOW - 1_000, NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000]) { - const cut = entry('cut', 'done', at, { - mainAgent: { state: 'done', outcome: 'interruption', stateStartedAt: at } - }) - expect(resolveRecentWorkspaceTabStatus(row('cut'), sources([cut]), NOW)).toBe('interrupted') - } - }) - it("reads a user's Stop as interrupted though attention demotes it, whether recorded or an old host's flag", () => { const recorded = entry('stopped', 'done', NOW - 1_000, { mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: NOW - 1_000 } diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.ts b/src/renderer/src/lib/recent-workspace-tab-rows.ts index 223665494f15..124ab24535c8 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.ts @@ -8,7 +8,7 @@ import { } from '@/components/sidebar/smart-attention' import { tabHasLivePty } from './tab-has-live-pty' import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' -import { isAgentStatusShownOnDot } from './pane-agent-evidence' +import { isExplicitAgentStatusFresh } from './pane-agent-evidence' import type { WorktreeStatus } from './worktree-status' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -80,7 +80,8 @@ export function resolveRecentWorkspaceTabStatus( } const verdicts = new Set( panes.flatMap((pane) => - pane.kind === 'hook' && isAgentStatusShownOnDot(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS) + pane.kind === 'hook' && + isExplicitAgentStatusFresh(pane.entry, now, AGENT_STATUS_STALE_AFTER_MS) ? [agentVerdictDisplayMark(pane.entry)] : [] ) diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index cd066a63a99c..fd9930adbe24 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -105,6 +105,7 @@ export type AgentStatusMetadata = { authorityRestartId?: string /** Structured status rows remain fresh while the host owns the session; cleared on feed loss. */ structuredHostOwned?: true + statusSource?: AgentStatusEntry['statusSource'] providerSession?: AgentProviderSessionMetadata launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index cd4c6da3d47f..05cbf28d4982 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -208,6 +208,7 @@ export function buildAgentStatusLiveEntry( ? { evidenceObservedAt: timing.evidenceObservedAt } : {}), ...(metadata?.structuredHostOwned === true ? { structuredHostOwned: true as const } : {}), + ...(metadata?.statusSource ? { statusSource: metadata.statusSource } : {}), stateStartedAt, stateObservedAt, ...(turnStartedAt !== undefined ? { turnStartedAt } : {}), diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 3d5ef9458e6c..ee9e8dd8a934 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -90,6 +90,9 @@ export type AgentStatusEntry = { evidenceObservedAt?: number /** True only while a host-held structured session is represented by its live status feed. */ structuredHostOwned?: true + /** Renderer-local: a native chat's row, which its host re-derives from the chat's journal on every + * change. Absent on hook and terminal reports, which can go quiet without a final update. */ + statusSource?: 'structured-journal' /** Timestamp (ms) when the current `state` was first reported. * Why: separate from updatedAt so tool/prompt pings (which reset updatedAt) don't move it. */ stateStartedAt: number From 7a42ad41ace6d32a5947ea3145ea165991c37d72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:00:37 -0700 Subject: [PATCH 07/24] fix(native-chat): a collapsed turn that was cut short still shows why it stopped A settled turn folds every row but its answer. The interrupted notice is muted, so it was neither an answer nor exempt, and folded away with the turn's work: by default nothing said the turn was cut short. The notice now never folds, as a compaction report doesn't; the partial answer stays the answer and the notice sits under it. The phone hides only tool activity behind its turn caret, so the notice was already shown there. --- ...ured-journal-restore-turn-grouping.test.ts | 2 +- ...veChatMessageList.cut-notice-fold.test.tsx | 157 ++++++++++++++++++ .../native-chat-transcript-slots.ts | 6 +- src/shared/native-chat-turn-fold.test.ts | 6 +- src/shared/native-chat-turn-fold.ts | 12 +- 5 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx 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 7c636545daff..c1ba93bba152 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 @@ -97,7 +97,7 @@ describe('grouping a Codex thread restored from full history', () => { rendersProse: true, outlivesTurn: false, reportsFailure: false, - reportsCompaction: false + explainsTurn: false })), settledTurnKeys: new Set(bars.settledTurns.keys()), expandedTurnKeys: new Set() diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx new file mode 100644 index 000000000000..e5a20801114a --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { agentSessionResponseInterruptedBody } from '../../../../shared/agent-session-host-status-rows' +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const NOTICE = 'This response was interrupted. You can continue in this conversation.' +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const IN_CUT: AgentJournalTurnScope = { kind: 'turn', turnItemId: 't1' } +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope | null +): AgentJournalRenderItem { + sequence += 1 + return { + itemId, + revision: 0, + sequence, + observedAt: 1_000_000 + sequence, + body, + ...(turnScope ? { turnScope } : {}) + } +} + +const say = (itemId: string, text: string, scope: AgentJournalTurnScope | null) => + item(itemId, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, scope) + +/** A turn cut 12 s in, after it had narrated and started an answer; null scopes for an older host. */ +function cutTurn(scoped: boolean, replies = true): AgentJournalRenderItem[] { + const scope = (value: AgentJournalTurnScope) => (scoped ? value : null) + return [ + item( + 'u1', + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'please do it' }] }, + scope(THREAD) + ), + item( + 't1', + { + kind: 'turn', + turnId: 't1', + userItemId: 'u1', + startedAt: 1_000_000, + requestedAt: 1_000_000, + completedAt: 1_012_000, + state: 'interrupted' + }, + scope(THREAD) + ), + ...(replies + ? [say('a1', 'narration a1', scope(IN_CUT)), say('a2', 'partial answer a2', scope(IN_CUT))] + : []) + ] +} + +const reopenRow = (body: AgentJournalItemBody) => + item( + agentJournalItemKey({ provider: 'orca', clientMessageId: 'stale-session:s:death-3-2000' }), + body, + IN_CUT + ) + +function renderCollapsed(journal: readonly AgentJournalRenderItem[]) { + const items = withNativeChatCutTurnNotices(journal) + render( + + ) + expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) +} + +// The notice is the one row that says why the turn stopped, so the collapsed turn never hides it: +// the partial answer stays the answer and the muted line sits under it. +describe('a collapsed turn that was cut short', () => { + it.each([ + ['derived for a quit, on a host that states scopes', () => cutTurn(true)], + ['derived for a quit, on a host that states none', () => cutTurn(false)], + [ + "the host's reopen row", + () => [...cutTurn(true), reopenRow(agentSessionResponseInterruptedBody())] + ], + [ + "an older host's red reopen row, re-presented", + () => [ + ...cutTurn(true), + reopenRow({ + kind: 'status', + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + }) + ] + ] + ])('shows its answer and the notice: %s', (_label, journal) => { + renderCollapsed(journal()) + + expect(screen.getByText('partial answer a2')).toBeInTheDocument() + expect(screen.queryByText('narration a1')).toBeNull() + expect(screen.getAllByText(NOTICE)).toHaveLength(1) + }) + + it('shows the notice when the turn wrote no answer', () => { + renderCollapsed([ + ...cutTurn(true, false), + item( + 'tool-1', + { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'failed' }, + IN_CUT + ) + ]) + + expect(screen.getAllByText(NOTICE)).toHaveLength(1) + }) +}) 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 343ee1c22a5c..8e9ad92c2957 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 @@ -128,8 +128,10 @@ export function buildNativeChatTranscriptSlots( (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) ), reportsFailure: message.blocks.some((block) => block.type === 'text' && block.tone === 'error'), - reportsCompaction: message.blocks.some( - (block) => block.type === 'text' && block.presentation === 'compaction' + explainsTurn: message.blocks.some( + (block) => + block.type === 'text' && + (block.presentation === 'compaction' || block.presentation === 'response-interrupted') ) })) // Liveness is the turn's, not any one call's: the run at the frontier stays diff --git a/src/shared/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index bbdff9366f15..1272a40eb5f3 100644 --- a/src/shared/native-chat-turn-fold.test.ts +++ b/src/shared/native-chat-turn-fold.test.ts @@ -12,7 +12,7 @@ function row(overrides: Partial = {}): NativeChatTurnFold rendersProse: true, outlivesTurn: false, reportsFailure: false, - reportsCompaction: false, + explainsTurn: false, ...overrides } } @@ -198,9 +198,9 @@ describe('nativeChatTurnFold', () => { const rows = [ row({ role: 'user' }), row(), - row({ role: 'system', reportsCompaction: true }), + row({ role: 'system', explainsTurn: true }), row(), - row({ role: 'system', reportsCompaction: true }) + row({ role: 'system', explainsTurn: true }) ] const { foldedRows } = nativeChatTurnFold({ rows, diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 1b502a7f6939..4edfa3f487d1 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -24,9 +24,10 @@ export type NativeChatTurnFoldRow = { /** 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 + /** Whether the row explains what became of the turn: a compaction's result, or + * that the response was interrupted. It never folds: hiding it would leave the + * turn's status as the only trace of it. */ + explainsTurn: boolean } export type NativeChatTurnFold = { @@ -84,12 +85,13 @@ 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 a compaction report explains it. + // turn, a roster or background-task row outlives it, and a compaction report or interruption + // notice explains it. if ( turnKey === undefined || row.role === 'user' || row.outlivesTurn || - row.reportsCompaction || + row.explainsTurn || !settledTurnKeys.has(turnKey) ) { continue From 66b9318e070fa4e30e8150c6f109edbab324c322 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:03:09 -0700 Subject: [PATCH 08/24] fix(native-chat): re-word only an older host's red reopen row The reader re-worded every reopen row by its id, so a newer host's own wording for that row (a recorded cause, for instance) would be replaced by the generic sentence on this client, and an early build's row lost the exit detail it quoted. Only the legacy shape (no presentation, error red or the exit fact) is re-worded now; anything else is kept as written. Comments no longer claim the owner that went away was Orca: the evidence proves only that the agent's process is gone. --- ...tured-agent-session-crash-turn-end.test.ts | 4 +-- ...gent-session-dead-generation-settlement.ts | 6 ++-- src/shared/agent-session-host-status-rows.ts | 4 +-- .../native-chat-cut-turn-notice.test.ts | 29 ++++++++++++++++++- src/shared/native-chat-cut-turn-notice.ts | 19 ++++++++---- 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 09d2867fb21e..bed7b3d793b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -224,8 +224,8 @@ describe('a turn a crash cut short mid-tool', () => { await host.restoreReadableSessions() const { items } = await host.journalSnapshot(SESSION) - // As a reader's transcript shows it: the stored row is the explanation, so none is derived. Orca - // went away, not the agent, so the row blames no one and is muted, not an error. + // As a reader's transcript shows it: the stored row is the explanation, so none is derived. + // Nothing proves who ended the agent's process, so the row blames no one and is muted. const statusRows = withNativeChatCutTurnNotices(items).flatMap((item) => item.body.kind === 'status' ? [item.body] : [] ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 452c58233e3b..28f8c151b181 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -263,8 +263,10 @@ export async function settleStaleStructuredAgentSessionState(input: { provider: 'orca', clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` }, - // The death evidence is Orca's log text, never a sentence for a person. The owner that went - // away was Orca, not the agent, so the row says only that the response was interrupted. + // The death evidence is Orca's log text, never a sentence for a person, and it proves only that + // the agent's process is gone, not who ended it: a quit's close and an agent exit whose own + // settle failed both read `exit-observed`. So the row blames no one. Written, not left to the + // reader's derived notice, because a client older than that notice sees only this row. body: agentSessionResponseInterruptedBody(), turnScope: runningRootTurnScope(items) }) diff --git a/src/shared/agent-session-host-status-rows.ts b/src/shared/agent-session-host-status-rows.ts index 30c77ae325c6..77bd15b4b13a 100644 --- a/src/shared/agent-session-host-status-rows.ts +++ b/src/shared/agent-session-host-status-rows.ts @@ -8,8 +8,8 @@ export const AGENT_SESSION_HOST_STATUS_COPY = { 'history-repaired': "Part of this chat's history couldn't be loaded.", /** In place of an item too large for any history page. */ 'history-item-too-large': 'This part of the chat was too large to show.', - /** A turn cut short with nobody asking (a crash, quit, restart or eviction), worded for every cause - * because a reader can't tell them apart. */ + /** A turn cut short with nobody asking (a crash, quit, restart or eviction, or an agent exit whose + * own settle failed), worded for every cause because a reader can't tell them apart. */ 'response-interrupted': 'This response was interrupted. You can continue in this conversation.' } as const diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index de4af3feef10..1b0ec1deae91 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -164,7 +164,7 @@ describe('withNativeChatCutTurnNotices', () => { } }) - // The owner that went away was Orca, so an older host's red "the agent stopped" blamed the agent. + // An older host's red "the agent stopped" blamed the agent for a process nothing proves it ended. it("words an owner's proven death as the notice does, and leaves the agent's own exit row as written", () => { const owner = exitRow('stale-session:s:death-3-2000', inTurn('t1')) const exit = exitRow('provider-exit:s:3:gen', inTurn('t2')) @@ -188,6 +188,33 @@ describe('withNativeChatCutTurnNotices', () => { expect(withNativeChatCutTurnNotices(items)[2]).toBe(derived[2]) }) + // A row that names its presentation is a newer host's own wording (a cause it recorded, say), and + // an early build's untoned row quotes the exit's detail: each is kept as written. + it('keeps a reopen row that is not the legacy red shape as written', () => { + const newer = hostRow( + 'stale-session:s:death-3-2000', + { + kind: 'status', + text: 'Orca quit while this response was in progress. You can continue in this conversation.', + presentation: 'response-interrupted-orca-quit', + tone: 'notice' + }, + inTurn('t1') + ) + const early = hostRow( + 'stale-session:s:3:seq-8', + { + kind: 'status', + text: 'The provider stopped while this response was in progress: Not logged in. You can continue in this conversation.' + }, + inTurn('t1') + ) + for (const row of [newer, early]) { + const items = [user('u1'), turn('t1', 'u1', CUT), row] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + } + }) + // A host from before failure facts wrote the same rows with only their words. it("counts an older host's exit row by its writer, with no exit fact", () => { for (const id of ['provider-exit:s:3:gen', 'stale-session:s:death-3-2000']) { diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index c090f7eeba90..ea8321d6d530 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -5,6 +5,7 @@ // explanation, so nothing is said twice. Shared by desktop and mobile, whose transcripts must agree. // The journal names no cause a reader can see, so the words fit every cause and blame no one. +import { readAgentSessionFailureFact } from './agent-session-failure' import { agentSessionResponseInterruptedBody } from './agent-session-host-status-rows' import { agentJournalItemKey } from './agent-session-journal-item-key' import type { AgentJournalRenderItem } from './agent-session-journal-types' @@ -80,10 +81,18 @@ function cutTurnNotice( const ownerDeathRowCache = new WeakMap() -/** A reopen's row about an owner found dead, in the notice's words: that owner was Orca, so an older - * host's "the agent stopped" blamed the agent, and in error red. */ +/** An older host's reopen row about an agent process found gone, in the notice's words: it said + * "the agent stopped", in error red, though the evidence only proves the process is gone, not who + * ended it. Only that legacy shape: a row that names its presentation, and an early build's row + * that quotes the exit's detail untoned, are kept as written. */ function ownerDeathRowAsInterruption(item: AgentJournalRenderItem): AgentJournalRenderItem { - if (cutTurnStopExplanation(item) !== 'owner-death') { + if ( + cutTurnStopExplanation(item) !== 'owner-death' || + item.body.kind !== 'status' || + item.body.presentation !== undefined || + (item.body.tone !== 'error' && + readAgentSessionFailureFact(item.body.failure)?.kind !== 'providerExited') + ) { return item } const cached = ownerDeathRowCache.get(item) @@ -97,8 +106,8 @@ function ownerDeathRowAsInterruption(item: AgentJournalRenderItem): AgentJournal /** * The journal as the transcript reads it: each root turn cut short with nobody asking, and no row - * saying so, gets one muted notice right after the turn's last row, and a reopen's row about an - * owner found dead says the same. Returns `items` itself when neither applies. + * saying so, gets one muted notice right after the turn's last row, and an older host's reopen row + * about an agent process found gone says the same. Returns `items` itself when neither applies. */ export function withNativeChatCutTurnNotices( items: readonly AgentJournalRenderItem[] From 73611f1a4228bc79d3a7849dfa09e068818adc3c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:03:42 -0700 Subject: [PATCH 09/24] revert(notifications): keep the cut's notification wording as on main No completion event is sent for a cut, so the formatter's interruption arm is unreachable; the wording change described nothing a user could see. --- src/main/ipc/notification-options.ts | 7 ++++--- src/main/ipc/notifications-message-formatting.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/notification-options.ts b/src/main/ipc/notification-options.ts index e910522f4f97..b06987c4b75c 100644 --- a/src/main/ipc/notification-options.ts +++ b/src/main/ipc/notification-options.ts @@ -80,13 +80,14 @@ function formatAgentNotificationStatusText(args: NotificationDispatchRequest): s return translateMain('notifications.agentStatus.finished', 'finished') } switch (args.agentTurnOutcome) { + // A turn cut short by anything but the user is a fault, as a failure is. case 'failure': + case 'interruption': return translateMain('notifications.agentStatus.failed', 'failed') - // Why: a Stop the user asked for, a turn a newer request replaced or a crash cut short, or an - // end Orca cannot prove, still never reads finished. + // Why: a Stop the user asked for, a turn a newer request replaced, or an end Orca cannot + // prove, still never reads finished. case 'cancellation': case 'superseded': - case 'interruption': case 'unconfirmed': return translateMain('notifications.agentStatus.stopped', 'stopped') case 'success': diff --git a/src/main/ipc/notifications-message-formatting.test.ts b/src/main/ipc/notifications-message-formatting.test.ts index 0781fa0e99f1..bf96745fb4fb 100644 --- a/src/main/ipc/notifications-message-formatting.test.ts +++ b/src/main/ipc/notifications-message-formatting.test.ts @@ -319,8 +319,8 @@ describe('registerNotificationHandlers', () => { it.each([ { agentTurnOutcome: 'cancellation', word: 'stopped' }, { agentTurnOutcome: 'failure', word: 'failed' }, - // A crash, quit or restart cut it short: worded as the Stop it reads as, not a fault. - { agentTurnOutcome: 'interruption', word: 'stopped' }, + // A turn cut short by anything but the user is a fault, worded as one. + { agentTurnOutcome: 'interruption', word: 'failed' }, { agentTurnOutcome: 'unconfirmed', word: 'stopped' }, { agentTurnOutcome: 'superseded', word: 'stopped' }, { agentTurnOutcome: 'success', word: 'finished' }, From 06981f0f52d20a79369e51bdbdb0643b4faad9ed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:07:16 -0700 Subject: [PATCH 10/24] test(native-chat): type the explanation test's row scopes --- src/shared/native-chat-cut-turn-explanation.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/shared/native-chat-cut-turn-explanation.test.ts b/src/shared/native-chat-cut-turn-explanation.test.ts index 2bad4e98bbae..799ef32f6762 100644 --- a/src/shared/native-chat-cut-turn-explanation.test.ts +++ b/src/shared/native-chat-cut-turn-explanation.test.ts @@ -36,10 +36,14 @@ const cutTurn = (id: string, userId: string) => startedAt: 1_000, completedAt: 13_000 }) -const hostRow = (clientMessageId: string, body: AgentJournalItemBody, scope = THREAD) => +const hostRow = ( + clientMessageId: string, + body: AgentJournalItemBody, + scope: AgentJournalTurnScope = THREAD +) => item(agentJournalItemKey({ provider: 'orca', clientMessageId }), body, scope) /** The row the agent's own exit leaves: its fact, in error red. */ -const exitRow = (scope = THREAD) => +const exitRow = (scope: AgentJournalTurnScope = THREAD) => hostRow( 'provider-exit:s:1:g', { From 8fa691bc898600b624f9dd787735365e8f1ec42c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:09:17 -0700 Subject: [PATCH 11/24] style: format round-1 tests --- .../structured-agent-session-recovered-turn-clock.test.ts | 5 ++++- .../sidebar/worktree-agent-activity-summary.test.ts | 6 +++--- .../tab-bar/terminal-tab-activity-status.test.ts | 7 ++++++- src/shared/native-chat-cut-turn-explanation.test.ts | 8 +++++--- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index f4d0ce4197fe..518028d240bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -242,7 +242,10 @@ describe('a turn recovery settled after its host went away', () => { it('reads Failed after N, marked failed, for a turn the agent cut by exiting on its own', async () => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) - await settleDeadGeneration(session.journal, { state: 'interrupted', completedAt: EXIT_OBSERVED }) + await settleDeadGeneration(session.journal, { + state: 'interrupted', + completedAt: EXIT_OBSERVED + }) session.publish() const [row] = session.server.getStatusSnapshot() diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index f100b436aa5b..5a8b4cc0cb0e 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -369,9 +369,9 @@ describe('selectWorktreeAgentActivitySummary', () => { hasInterrupted: false }) // A native chat's stale working row is not a verdict, so it ages out as any report does. - expect( - staleCard(ended('failure', { state: 'working', mainAgent: undefined })) - ).toMatchObject({ hasLiveWorking: false, hasFailed: false }) + expect(staleCard(ended('failure', { state: 'working', mainAgent: undefined }))).toMatchObject( + { hasLiveWorking: false, hasFailed: false } + ) }) it('reads a retained cut-short agent as interrupted, not done', () => { diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index c88c20de3a3a..ff56a3fbc532 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -260,7 +260,12 @@ describe('resolveTerminalTabActivityStatus', () => { expect(status(ended('cancellation', { statusSource: undefined }))).not.toBe('interrupted') // The next turn replaces it. expect( - status(entry(FIRST_LEAF_ID, 'working', { updatedAt: Date.now(), statusSource: 'structured-journal' })) + status( + entry(FIRST_LEAF_ID, 'working', { + updatedAt: Date.now(), + statusSource: 'structured-journal' + }) + ) ).toBe('working') }) diff --git a/src/shared/native-chat-cut-turn-explanation.test.ts b/src/shared/native-chat-cut-turn-explanation.test.ts index 799ef32f6762..c5b78f3937e4 100644 --- a/src/shared/native-chat-cut-turn-explanation.test.ts +++ b/src/shared/native-chat-cut-turn-explanation.test.ts @@ -40,8 +40,7 @@ const hostRow = ( clientMessageId: string, body: AgentJournalItemBody, scope: AgentJournalTurnScope = THREAD -) => - item(agentJournalItemKey({ provider: 'orca', clientMessageId }), body, scope) +) => item(agentJournalItemKey({ provider: 'orca', clientMessageId }), body, scope) /** The row the agent's own exit leaves: its fact, in error red. */ const exitRow = (scope: AgentJournalTurnScope = THREAD) => hostRow( @@ -76,7 +75,10 @@ function readings(items: readonly AgentJournalRenderItem[]) { describe('structuredAgentTurnVerdictReader', () => { it.each([ ['scoped to the turn', () => [user('u1'), cutTurn('t1', 'u1'), exitRow(IN_CUT)]], - ['about the conversation, right after the cut', () => [user('u1'), cutTurn('t1', 'u1'), exitRow()]], + [ + 'about the conversation, right after the cut', + () => [user('u1'), cutTurn('t1', 'u1'), exitRow()] + ], [ // The exit settle wrote its row but not the turn's end, which the next reopen then wrote. 'followed by a reopen row for the same death', From 13aad78b8f4124e2642661359a05cf21ffc82871 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:14:42 -0700 Subject: [PATCH 12/24] fix(native-chat): a re-worded reopen or quit row keeps what its host stored beside the words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-wording an older red row built a new body, dropping any field a host stored on it, such as why Orca stopped. It now changes only the words, presentation and tone. A quit's row in the same family (`stale-session:…:shutdown-…`) already counts as the cut's explanation by its writer prefix; a test now pins that, and that such a row with its own presentation is kept as written. --- .../native-chat-cut-turn-notice.test.ts | 35 ++++++++++++++++++- src/shared/native-chat-cut-turn-notice.ts | 11 +++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 1b0ec1deae91..fe2c9861bc05 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -9,6 +9,7 @@ import type { AgentJournalTurnOutcome, AgentJournalTurnScope } from './agent-session-journal-types' +import { structuredAgentTurnVerdictReader } from './native-chat-cut-turn-explanation' import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' import { hostStatesTurnScopes, nativeChatTurnMembership } from './native-chat-turn-membership' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' @@ -182,7 +183,7 @@ describe('withNativeChatCutTurnNotices', () => { expect(derived).toHaveLength(items.length) expect(derived[2]).toEqual({ ...owner, - body: { kind: 'status', text: NOTICE, presentation: 'response-interrupted', tone: 'notice' } + body: { ...owner.body, text: NOTICE, presentation: 'response-interrupted', tone: 'notice' } }) expect(derived[5]).toBe(exit) expect(withNativeChatCutTurnNotices(items)[2]).toBe(derived[2]) @@ -215,6 +216,38 @@ describe('withNativeChatCutTurnNotices', () => { } }) + // A quit's row in the same family (`stale-session:…:shutdown-…`) explains the cut as a reopen's does. + // In the older red words it is re-worded, keeping what the host stored beside them (why Orca + // stopped, for a client that names it); with its own presentation it is kept as written. + it("takes a quit's row as the cut's explanation and keeps what the host stored on it", () => { + const stored = { ...exitWords(), orcaStop: { cause: 'update' } } + const red = hostRow('stale-session:s:shutdown-3-gen', stored, inTurn('t1')) + const redItems = [user('u1'), turn('t1', 'u1', CUT), red] + + expect(notices(redItems)).toEqual([]) + expect(withNativeChatCutTurnNotices(redItems)[2]!.body).toEqual({ + ...stored, + text: NOTICE, + presentation: 'response-interrupted', + tone: 'notice' + }) + // A quit is not the agent's fault, though the row carries the exit fact. + expect(structuredAgentTurnVerdictReader(redItems)(redItems[1]!)).toBe('interruption') + + const worded = hostRow( + 'stale-session:s:shutdown-3-gen', + { + kind: 'status', + text: 'Orca quit while this response was in progress.', + presentation: 'orca-stop-cut', + tone: 'notice' + }, + inTurn('t1') + ) + const wordedItems = [user('u1'), turn('t1', 'u1', CUT), worded] + expect(withNativeChatCutTurnNotices(wordedItems)).toBe(wordedItems) + }) + // A host from before failure facts wrote the same rows with only their words. it("counts an older host's exit row by its writer, with no exit fact", () => { for (const id of ['provider-exit:s:3:gen', 'stale-session:s:death-3-2000']) { diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index ea8321d6d530..5be433b347f3 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -81,10 +81,10 @@ function cutTurnNotice( const ownerDeathRowCache = new WeakMap() -/** An older host's reopen row about an agent process found gone, in the notice's words: it said - * "the agent stopped", in error red, though the evidence only proves the process is gone, not who - * ended it. Only that legacy shape: a row that names its presentation, and an early build's row - * that quotes the exit's detail untoned, are kept as written. */ +/** A host's row about an agent process gone from under a turn (a reopen's, or a quit's), in the + * notice's words when it is in the older red form: "the agent stopped", in error red, though the + * evidence only proves the process is gone, not who ended it. A row that names its presentation, + * and an early build's row that quotes the exit's detail untoned, are kept as written. */ function ownerDeathRowAsInterruption(item: AgentJournalRenderItem): AgentJournalRenderItem { if ( cutTurnStopExplanation(item) !== 'owner-death' || @@ -99,7 +99,8 @@ function ownerDeathRowAsInterruption(item: AgentJournalRenderItem): AgentJournal if (cached) { return cached } - const reworded = { ...item, body: agentSessionResponseInterruptedBody() } + // Only the words change: a field a newer host stored beside them (why Orca stopped) is kept. + const reworded = { ...item, body: { ...item.body, ...agentSessionResponseInterruptedBody() } } ownerDeathRowCache.set(item, reworded) return reworded } From 5a53171e4e0ffb4d1fa02db20d57140e1d98c4c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:17:01 -0700 Subject: [PATCH 13/24] fix(native-chat): a re-worded row drops only the failure fact its old words came from The status row type ties a failure fact to the sentence built from it, so the fact cannot stay beside the new words. Every other stored field (why Orca stopped) is kept. --- src/shared/native-chat-cut-turn-notice.test.ts | 7 ++++--- src/shared/native-chat-cut-turn-notice.ts | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index fe2c9861bc05..67c4f718214d 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -183,7 +183,7 @@ describe('withNativeChatCutTurnNotices', () => { expect(derived).toHaveLength(items.length) expect(derived[2]).toEqual({ ...owner, - body: { ...owner.body, text: NOTICE, presentation: 'response-interrupted', tone: 'notice' } + body: { kind: 'status', text: NOTICE, presentation: 'response-interrupted', tone: 'notice' } }) expect(derived[5]).toBe(exit) expect(withNativeChatCutTurnNotices(items)[2]).toBe(derived[2]) @@ -226,10 +226,11 @@ describe('withNativeChatCutTurnNotices', () => { expect(notices(redItems)).toEqual([]) expect(withNativeChatCutTurnNotices(redItems)[2]!.body).toEqual({ - ...stored, + kind: 'status', text: NOTICE, presentation: 'response-interrupted', - tone: 'notice' + tone: 'notice', + orcaStop: { cause: 'update' } }) // A quit is not the agent's fault, though the row carries the exit fact. expect(structuredAgentTurnVerdictReader(redItems)(redItems[1]!)).toBe('interruption') diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index 5be433b347f3..9916147aadcd 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -99,8 +99,10 @@ function ownerDeathRowAsInterruption(item: AgentJournalRenderItem): AgentJournal if (cached) { return cached } - // Only the words change: a field a newer host stored beside them (why Orca stopped) is kept. - const reworded = { ...item, body: { ...item.body, ...agentSessionResponseInterruptedBody() } } + // Only the words change, and the failure fact the old words were built from goes with them: a + // field a newer host stored beside them (why Orca stopped) is kept. + const { failure: _failure, ...stored } = item.body + const reworded = { ...item, body: { ...stored, ...agentSessionResponseInterruptedBody() } } ownerDeathRowCache.set(item, reworded) return reworded } From 8d478f291c1eec8e86f07a4a65ebca7af4eac927 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:51:39 -0700 Subject: [PATCH 14/24] fix(native-chat): an old chat verdict yields to live work and a fresh finish on the card A native chat's settled verdict kept past the freshness window went through the card's fresh flags, so a week-old Failed pinned the worktree card red over another agent's live work, and an old Stop, cut or "Couldn't confirm" hid another agent's fresh Done. A mark kept only because it never expires now lands in the card's retained tier, as a departed agent's does: live work and a fresh finish show over it, and alone on the card it still shows. The row's provenance is now the host's own `structuredHost` ('owned' | 'held'), stamped by the status bridge as the host ingest stamps it and compared in the bridge's equality check, instead of a second renderer-only field. --- ...tructuredAgentSessionStatusBridge.test.tsx | 2 +- .../StructuredAgentSessionStatusBridge.tsx | 5 +- .../sidebar/use-worktree-activity-status.ts | 10 +- .../sidebar/use-worktree-activity-statuses.ts | 6 +- .../worktree-agent-activity-summary.test.ts | 93 ++++++++++++++++--- .../worktree-agent-activity-summary.ts | 58 ++++++++---- .../terminal-tab-activity-status.test.ts | 11 ++- .../src/lib/pane-agent-evidence.test.ts | 4 +- src/renderer/src/lib/pane-agent-evidence.ts | 35 ++++--- src/renderer/src/lib/worktree-status.ts | 15 ++- .../src/store/slices/agent-status-contract.ts | 2 +- .../slices/agent-status-live-entry-builder.ts | 2 +- src/shared/agent-status-types.ts | 8 +- 13 files changed, 190 insertions(+), 61 deletions(-) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index c674a556c0b1..272e2019c037 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -220,7 +220,7 @@ describe('StructuredAgentSessionStatusBridge', () => { ) act(() => feed().emit({ type: 'end' })) const entry = statuses()[0]! - expect(entry).toMatchObject({ state: 'done', statusSource: 'structured-journal' }) + expect(entry).toMatchObject({ state: 'done', structuredHost: 'held' }) expect(isExplicitAgentStatusFresh(entry, Date.now(), 30 * 60 * 1000)).toBe(false) expect(isAgentStatusShownOnDot(entry, Date.now(), 30 * 60 * 1000)).toBe(true) }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 74f31d6a2f2e..7272497d33b3 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -115,6 +115,8 @@ function projectStatus( turnOutcome: summary.turnOutcome }) const current = store.agentStatusByPaneKey?.[paneKey] + // The host's own provenance for the row, as its ingest stamps it. + const structuredHost = summary.hostExecutionOwned ? 'owned' : 'held' // Same continuity rule as the host ingest, on the main agent's own clock. const mainAgent = continueMainAgentStatus( current?.mainAgent, @@ -161,6 +163,7 @@ function projectStatus( current.worktreeId === tab.worktreeId && current.terminalResumeEligible === false && current.structuredHostOwned === summary.hostExecutionOwned && + current.structuredHost === structuredHost && agentProviderSessionsEqual( tab.agentSessionAgent, current.providerSession, @@ -194,7 +197,7 @@ function projectStatus( { ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), terminalResumeEligible: false, - statusSource: 'structured-journal', + structuredHost, ...(summary.hostExecutionOwned ? { structuredHostOwned: true as const } : {}) } ) diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index 52529122c2a4..34e00bd44af7 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -32,6 +32,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { hasLiveDone, hasRetainedDone, hasRetainedFailed, + hasRetainedInterrupted, + hasRetainedUnconfirmed, agentStatusPaneIdsByTabId, stalePaneIdsByTabId } = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId))) @@ -57,7 +59,9 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { hasUnconfirmed, hasLiveDone, hasRetainedDone, - hasRetainedFailed + hasRetainedFailed, + hasRetainedInterrupted, + hasRetainedUnconfirmed }), [ tabs, @@ -75,7 +79,9 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { hasUnconfirmed, hasLiveDone, hasRetainedDone, - hasRetainedFailed + hasRetainedFailed, + hasRetainedInterrupted, + hasRetainedUnconfirmed ] ) } diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts index 388b1ae7d886..b506c84a3953 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -40,6 +40,8 @@ export function selectWorktreeActivityStatuses( hasLiveDone, hasRetainedDone, hasRetainedFailed, + hasRetainedInterrupted, + hasRetainedUnconfirmed, agentStatusPaneIdsByTabId, stalePaneIdsByTabId } = selectWorktreeAgentActivitySummary(statusInputs, worktreeId) @@ -61,7 +63,9 @@ export function selectWorktreeActivityStatuses( hasUnconfirmed, hasLiveDone, hasRetainedDone, - hasRetainedFailed + hasRetainedFailed, + hasRetainedInterrupted, + hasRetainedUnconfirmed }) ) } diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 5a8b4cc0cb0e..961d96b6c335 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -341,39 +341,103 @@ describe('selectWorktreeAgentActivitySummary', () => { state: 'done', mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 } }), - statusSource: 'structured-journal', + structuredHost: 'held', ...overrides }) - const staleCard = (row: AgentStatusEntry) => { + const staleCard = (...rows: AgentStatusEntry[]) => { vi.spyOn(Date, 'now').mockReturnValue(1_000 + AGENT_STATUS_STALE_AFTER_MS + 60_000) - return selectWorktreeAgentActivitySummary( + const summary = selectWorktreeAgentActivitySummary( { tabsByWorktree: { [worktreeId]: [liveTab] }, agentStatusEpoch: epoch++, - agentStatusByPaneKey: { [failedKey]: row }, + agentStatusByPaneKey: Object.fromEntries(rows.map((row) => [row.paneKey, row])), migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, retainedAgentsByPaneKey: {} }, worktreeId ) + return { + ...summary, + status: resolveWorktreeStatus({ + tabs: [], + browserTabs: [], + ptyIdsByTabId: {}, + ...summary + }) + } } - expect(staleCard(ended('interruption'))).toMatchObject({ hasInterrupted: true }) - expect(staleCard(ended('cancellation'))).toMatchObject({ hasInterrupted: true }) - expect(staleCard(ended('failure'))).toMatchObject({ hasFailed: true }) - expect(staleCard(ended('unconfirmed'))).toMatchObject({ hasUnconfirmed: true }) + // Alone on the card, each still shows. + expect(staleCard(ended('interruption'))).toMatchObject({ + hasRetainedInterrupted: true, + status: 'interrupted' + }) + expect(staleCard(ended('cancellation'))).toMatchObject({ + hasRetainedInterrupted: true, + status: 'interrupted' + }) + expect(staleCard(ended('failure'))).toMatchObject({ + hasRetainedFailed: true, + hasFailed: false, + status: 'failed' + }) + expect(staleCard(ended('unconfirmed'))).toMatchObject({ + hasRetainedUnconfirmed: true, + status: 'unconfirmed' + }) // A clean done is news that ages; a terminal hook row can go quiet; neither is kept. - expect(staleCard(ended('success'))).toMatchObject({ hasLiveDone: false }) - expect(staleCard(ended('cancellation', { statusSource: undefined }))).toMatchObject({ - hasInterrupted: false + expect(staleCard(ended('success'))).toMatchObject({ + hasLiveDone: false, + hasRetainedDone: false + }) + expect(staleCard(ended('cancellation', { structuredHost: undefined }))).toMatchObject({ + hasInterrupted: false, + hasRetainedInterrupted: false }) // A native chat's stale working row is not a verdict, so it ages out as any report does. expect(staleCard(ended('failure', { state: 'working', mainAgent: undefined }))).toMatchObject( - { hasLiveWorking: false, hasFailed: false } + { hasLiveWorking: false, hasFailed: false, hasRetainedFailed: false } ) }) + // A mark that never expires must not hide what another agent is doing or just finished. + it("shows live work and a fresh finish over a native chat's old verdict", () => { + const at = 1_000 + AGENT_STATUS_STALE_AFTER_MS + 60_000 + const old = (outcome: 'failure' | 'cancellation' | 'interruption'): AgentStatusEntry => ({ + ...makeAgentStatusEntry({ + paneKey: failedKey, + state: 'done', + mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 } + }), + structuredHost: 'held' + }) + const fresh = (state: 'working' | 'done'): AgentStatusEntry => ({ + ...makeAgentStatusEntry({ paneKey: workingKey, state }), + updatedAt: at, + stateStartedAt: at + }) + const card = (...rows: AgentStatusEntry[]) => { + vi.spyOn(Date, 'now').mockReturnValue(at) + const summary = selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { [worktreeId]: [liveTab] }, + agentStatusEpoch: epoch++, + agentStatusByPaneKey: Object.fromEntries(rows.map((row) => [row.paneKey, row])), + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + worktreeId + ) + return resolveWorktreeStatus({ tabs: [], browserTabs: [], ptyIdsByTabId: {}, ...summary }) + } + + expect(card(fresh('working'), old('failure'))).toBe('working') + expect(card(fresh('done'), old('cancellation'))).toBe('done') + expect(card(old('interruption'))).toBe('interrupted') + }) + it('reads a retained cut-short agent as interrupted, not done', () => { const cut = { state: 'done', outcome: 'interruption', stateStartedAt: 1_000 } as const const departed = cardFor( @@ -385,7 +449,10 @@ describe('selectWorktreeAgentActivitySummary', () => { } } ) - expect(departed.summary).toMatchObject({ hasInterrupted: true, hasRetainedDone: false }) + expect(departed.summary).toMatchObject({ + hasRetainedInterrupted: true, + hasRetainedDone: false + }) expect(departed.status).toBe('interrupted') }) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index a3bc37e8b1b7..17446972a19a 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -1,5 +1,5 @@ import type { AppState } from '@/store' -import { isAgentStatusShownOnDot } from '@/lib/pane-agent-evidence' +import { isExplicitAgentStatusFresh, isSettledNativeChatVerdict } from '@/lib/pane-agent-evidence' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { mergeAgentStatusOrchestration, @@ -19,14 +19,18 @@ export type WorktreeAgentActivitySummary = { hasLiveMonitoring: boolean /** A fresh failed main agent, also while its subagents run; kept apart from clean done. */ hasFailed: boolean - /** An interrupted turn (fresh, cut short, or retained), kept separate from clean done outcomes. */ + /** A fresh interrupted turn, kept separate from clean done outcomes. */ hasInterrupted: boolean /** A fresh end Orca cannot prove, likewise never a clean done. */ hasUnconfirmed: boolean hasLiveDone: boolean hasRetainedDone: boolean - /** A departed agent's failure; unlike `hasFailed` it yields to live work. */ + /** A failure with no expiry (a departed agent's, or a native chat's kept past the freshness + * window); unlike `hasFailed` it yields to live work. The retained marks below yield to a + * fresh finish too. */ hasRetainedFailed: boolean + hasRetainedInterrupted: boolean + hasRetainedUnconfirmed: boolean agentStatusPaneIdsByTabId: Record> /** Stale rows suppress generated permission labels while preserving native title fallback. */ stalePaneIdsByTabId: Record> @@ -44,6 +48,8 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = { hasLiveDone: false, hasRetainedDone: false, hasRetainedFailed: false, + hasRetainedInterrupted: false, + hasRetainedUnconfirmed: false, agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID, stalePaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID } @@ -133,7 +139,8 @@ function getWorktreeAgentActivitySummaries( addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) continue } - if (!isAgentStatusShownOnDot(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + const fresh = isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + if (!fresh && !isSettledNativeChatVerdict(entry)) { // Why: staleness ends this row's authority but not the pane's identity — see // `stalePaneIdsByTabId`. Dropping both let Orca's self-authored permission title outlive // the row it came from and pin the card to a question nobody was asking. @@ -144,7 +151,11 @@ function getWorktreeAgentActivitySummaries( if (entry.state === 'done') { addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) } - applyAgentPaneActivityFlags(summary, entry) + if (fresh) { + applyAgentPaneActivityFlags(summary, entry) + } else { + applyRetainedAgentMark(summary, entry) + } } for (const unsupported of Object.values(state.migrationUnsupportedByPtyId ?? {})) { @@ -157,18 +168,7 @@ function getWorktreeAgentActivitySummaries( for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) { const summary = summaryForWorktree(retained.worktreeId) - // Why: a failed or cut-short agent is retained so that stays visible, not so it reads done. - switch (agentVerdictDisplayMark(retained.entry)) { - case 'failed': - summary.hasRetainedFailed = true - break - case 'interrupted': - summary.hasInterrupted = true - break - case 'unconfirmed': - case null: - summary.hasRetainedDone = true - } + applyRetainedAgentMark(summary, retained.entry) const paneIdentity = parseAgentStatusPaneIdentity(retained.entry?.paneKey) if (paneIdentity) { addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) @@ -203,6 +203,28 @@ function getWorktreeAgentActivitySummaries( return summaries } +/** A mark with no expiry: a departed agent's, or a native chat's settled verdict past the freshness + * window. It keeps showing, but in the retained tier, so live work and a fresh finish show over it; + * a failed or cut-short agent is retained so that stays visible, not so it reads done. */ +function applyRetainedAgentMark( + summary: WorktreeAgentActivitySummary, + entry: Parameters[0] +): void { + switch (agentVerdictDisplayMark(entry)) { + case 'failed': + summary.hasRetainedFailed = true + return + case 'interrupted': + summary.hasRetainedInterrupted = true + return + case 'unconfirmed': + summary.hasRetainedUnconfirmed = true + return + case null: + summary.hasRetainedDone = true + } +} + function summariesEqual( previous: WorktreeAgentActivitySummary, next: WorktreeAgentActivitySummary @@ -217,6 +239,8 @@ function summariesEqual( previous.hasLiveDone === next.hasLiveDone && previous.hasRetainedDone === next.hasRetainedDone && previous.hasRetainedFailed === next.hasRetainedFailed && + previous.hasRetainedInterrupted === next.hasRetainedInterrupted && + previous.hasRetainedUnconfirmed === next.hasRetainedUnconfirmed && agentStatusPaneIdsByTabIdEqual( previous.agentStatusPaneIdsByTabId, next.agentStatusPaneIdsByTabId diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index ff56a3fbc532..2111c20ef4e6 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -237,12 +237,12 @@ describe('resolveTerminalTabActivityStatus', () => { // window never clears it, whoever ended the turn. A hook row and a clean done still age out. it("keeps a native chat's settled verdict past the freshness window, until the chat changes", () => { const ended = ( - outcome: 'interruption' | 'cancellation' | 'failure' | 'success', + outcome: 'interruption' | 'cancellation' | 'failure' | 'unconfirmed' | 'success', overrides: Partial = {} ) => entry(FIRST_LEAF_ID, 'done', { updatedAt: 0, - statusSource: 'structured-journal', + structuredHost: 'held', mainAgent: { state: 'done', outcome, stateStartedAt: 0 }, ...overrides }) @@ -256,14 +256,17 @@ describe('resolveTerminalTabActivityStatus', () => { expect(status(ended('interruption'))).toBe('interrupted') expect(status(ended('cancellation'))).toBe('interrupted') expect(status(ended('failure'))).toBe('failed') + expect(status(ended('unconfirmed'))).toBe('unconfirmed') expect(status(ended('success'))).not.toBe('done') - expect(status(ended('cancellation', { statusSource: undefined }))).not.toBe('interrupted') + // A stale working row is not a verdict: it ages out as any report does. + expect(status(ended('failure', { state: 'working', mainAgent: undefined }))).not.toBe('working') + expect(status(ended('cancellation', { structuredHost: undefined }))).not.toBe('interrupted') // The next turn replaces it. expect( status( entry(FIRST_LEAF_ID, 'working', { updatedAt: Date.now(), - statusSource: 'structured-journal' + structuredHost: 'held' }) ) ).toBe('working') diff --git a/src/renderer/src/lib/pane-agent-evidence.test.ts b/src/renderer/src/lib/pane-agent-evidence.test.ts index 92d027c39832..bf68f5e1adab 100644 --- a/src/renderer/src/lib/pane-agent-evidence.test.ts +++ b/src/renderer/src/lib/pane-agent-evidence.test.ts @@ -55,7 +55,7 @@ describe('isAgentStatusShownOnDot', () => { entry({ state: 'done', updatedAt: STALE, - statusSource: 'structured-journal', + structuredHost: 'held', mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: STALE }, ...overrides }) @@ -68,7 +68,7 @@ describe('isAgentStatusShownOnDot', () => { }) it.each([ - ['a hook row', { statusSource: undefined }], + ['a hook row', { structuredHost: undefined }], ['a clean done', { mainAgent: { state: 'done', outcome: 'success', stateStartedAt: STALE } }], [ 'a failed lead still held open by its subagents', diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 4b46fb0515a9..ab0841433bf6 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -37,25 +37,34 @@ export function isExplicitAgentStatusFresh( } /** - * Whether a row still sets its pane's status dot: while its evidence is fresh, and, for a native - * chat's settled verdict (Interrupted, Failed, Couldn't confirm), until the chat's next turn - * replaces it. That verdict is the chat's state, re-derived from its journal on every change, not a - * live report that can go quiet, so no timer may clear it. A clean done, and a row still held open - * by work, decay as before. + * A native chat's settled verdict (Interrupted, Failed, Couldn't confirm) that no timer may clear: it + * is the chat's state, re-derived from its journal on every change, not a live report that can go + * quiet, so it stays until the chat's next turn replaces it. A clean done, and a row still held open + * by work, are not kept. */ +export function isSettledNativeChatVerdict( + entry: Pick< + AgentStatusEntry, + 'state' | 'interrupted' | 'mainAgent' | 'structuredHost' | 'restoredUnconfirmed' + > +): boolean { + return ( + entry.structuredHost !== undefined && + entry.restoredUnconfirmed !== true && + entry.state === 'done' && + agentVerdictDisplayMark(entry) !== null + ) +} + +/** Whether a row still sets its pane's status dot: while its evidence is fresh, or while it is a + * native chat's settled verdict. */ export function isAgentStatusShownOnDot( entry: Parameters[0] & - Pick, + Parameters[0], now: number, staleAfterMs: number ): boolean { - return ( - isExplicitAgentStatusFresh(entry, now, staleAfterMs) || - (entry.statusSource === 'structured-journal' && - entry.restoredUnconfirmed !== true && - entry.state === 'done' && - agentVerdictDisplayMark(entry) !== null) - ) + return isExplicitAgentStatusFresh(entry, now, staleAfterMs) || isSettledNativeChatVerdict(entry) } /** diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index ac50b69742bc..340e4548a044 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -171,7 +171,8 @@ export function getWorktreeStatusLabel(status: WorktreeStatus): string { * * Map args are narrowed to this worktree. `hasPermission`/`hasLiveWorking`/ * `hasLiveDone` are fresh hook entries ({blocked,waiting} / {working} / {done}); - * `hasRetainedDone`/`hasRetainedFailed` are retained-agent snapshots scoped to this worktreeId. + * `hasRetained*` are marks with no expiry (retained agents, kept native-chat verdicts) scoped to this + * worktreeId. */ export function resolveWorktreeStatus(args: { tabs: readonly Pick[] @@ -191,6 +192,8 @@ export function resolveWorktreeStatus(args: { hasLiveDone: boolean hasRetainedDone: boolean hasRetainedFailed?: boolean + hasRetainedInterrupted?: boolean + hasRetainedUnconfirmed?: boolean }): WorktreeStatus { const heuristic = getWorktreeStatus( args.tabs, @@ -223,7 +226,8 @@ export function resolveWorktreeStatus(args: { if (args.hasLiveMonitoring || heuristic === 'monitoring') { return 'monitoring' } - // Why: a departed agent's failure has no expiry, so it must not pin the card over live work. + // Why: a failure with no expiry (a departed agent's, an old native chat's) must not pin the card + // over live work. if (args.hasRetainedFailed) { return 'failed' } @@ -238,5 +242,12 @@ export function resolveWorktreeStatus(args: { if (args.hasLiveDone || args.hasRetainedDone) { return 'done' } + // Why: an old end that never expires must not hide another agent's fresh finish. + if (args.hasRetainedUnconfirmed) { + return 'unconfirmed' + } + if (args.hasRetainedInterrupted) { + return 'interrupted' + } return heuristic } diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index fd9930adbe24..e680d7e0a286 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -105,7 +105,7 @@ export type AgentStatusMetadata = { authorityRestartId?: string /** Structured status rows remain fresh while the host owns the session; cleared on feed loss. */ structuredHostOwned?: true - statusSource?: AgentStatusEntry['statusSource'] + structuredHost?: AgentStatusEntry['structuredHost'] providerSession?: AgentProviderSessionMetadata launchConfig?: SleepingAgentLaunchConfig launchToken?: string diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 05cbf28d4982..aed68374a13c 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -208,7 +208,7 @@ export function buildAgentStatusLiveEntry( ? { evidenceObservedAt: timing.evidenceObservedAt } : {}), ...(metadata?.structuredHostOwned === true ? { structuredHostOwned: true as const } : {}), - ...(metadata?.statusSource ? { statusSource: metadata.statusSource } : {}), + ...(metadata?.structuredHost ? { structuredHost: metadata.structuredHost } : {}), stateStartedAt, stateObservedAt, ...(turnStartedAt !== undefined ? { turnStartedAt } : {}), diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index ee9e8dd8a934..1ff272220e46 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -5,6 +5,7 @@ import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { AgentMainAgentStatus } from './main-agent-status' import type { AgentStateHistoryEntry } from './agent-state-history' +import type { StructuredHostStatus } from './agent-hook-listener/listener-event' import { isAgentTurnOutcome } from './agent-turn-outcome' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' @@ -90,9 +91,10 @@ export type AgentStatusEntry = { evidenceObservedAt?: number /** True only while a host-held structured session is represented by its live status feed. */ structuredHostOwned?: true - /** Renderer-local: a native chat's row, which its host re-derives from the chat's journal on every - * change. Absent on hook and terminal reports, which can go quiet without a final update. */ - statusSource?: 'structured-journal' + /** A native chat's row, re-derived by its host from the chat's journal on every change, and + * whether that host still holds its agent; absent on hook and terminal reports, which can go + * quiet without a final update. The host's own `structuredHost` provenance. */ + structuredHost?: StructuredHostStatus /** Timestamp (ms) when the current `state` was first reported. * Why: separate from updatedAt so tool/prompt pings (which reset updatedAt) don't move it. */ stateStartedAt: number From 452f21c41999f60e3cbfe88f3847a8fcb550279c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:01:07 -0700 Subject: [PATCH 15/24] fix(native-chat): a row naming why Orca stopped reads as the muted interrupted line A host that records why Orca stopped under a turn writes its row with presentation `orca-stop` and its cause beside the words, keeping the red words and exit fact for older clients. This client mutes it in the interrupted words, keeps the presentation and cause for a client that words them, never folds it with the turn's work, and never takes its red tone as the turn's failure report. --- ...veChatMessageList.cut-notice-fold.test.tsx | 36 +++++++++++++++++-- .../native-chat/NativeChatNoticeRow.test.tsx | 15 ++++++++ .../native-chat/NativeChatNoticeRow.tsx | 4 ++- .../native-chat-transcript-slots.ts | 11 ++++-- src/shared/agent-session-host-status-rows.ts | 11 +++++- .../native-chat-cut-turn-notice.test.ts | 19 ++++++++++ src/shared/native-chat-cut-turn-notice.ts | 36 ++++++++++++------- 7 files changed, 113 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx index e5a20801114a..710fb8a13caa 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx @@ -83,8 +83,8 @@ const reopenRow = (body: AgentJournalItemBody) => IN_CUT ) -function renderCollapsed(journal: readonly AgentJournalRenderItem[]) { - const items = withNativeChatCutTurnNotices(journal) +function renderCollapsed(journal: readonly AgentJournalRenderItem[], represent = true) { + const items = represent ? withNativeChatCutTurnNotices(journal) : journal render( { tone: 'error' }) ] + ], + [ + 'a row naming why Orca stopped, red for older clients', + () => [ + ...cutTurn(true), + reopenRow({ + kind: 'status', + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + presentation: 'orca-stop', + tone: 'error' + }) + ] ] ])('shows its answer and the notice: %s', (_label, journal) => { renderCollapsed(journal()) @@ -142,6 +154,26 @@ describe('a collapsed turn that was cut short', () => { expect(screen.getAllByText(NOTICE)).toHaveLength(1) }) + // Its red tone is for older clients: it is never the turn's failure report, so the partial answer + // stays the answer, even read before the re-wording. + it('never takes an orca-stop row as the failure the turn ended on', () => { + renderCollapsed( + [ + ...cutTurn(true), + reopenRow({ + kind: 'status', + text: 'Codex stopped while this response was in progress.', + presentation: 'orca-stop', + tone: 'error' + }) + ], + false + ) + + expect(screen.getByText('partial answer a2')).toBeInTheDocument() + expect(screen.getAllByText(NOTICE)).toHaveLength(1) + }) + it('shows the notice when the turn wrote no answer', () => { renderCollapsed([ ...cutTurn(true, false), diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index 960bab423223..f489dc26efcc 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -118,6 +118,21 @@ describe('notice rows', () => { expect(line).toHaveClass('text-muted-foreground') expect(line.closest('.text-destructive')).toBeNull() }) + // A row naming why Orca stopped stays red for clients that fold every other row; this client says + // only that the response was interrupted, muted. + it('draws an orca-stop row as the muted interrupted line, even in its red tone', () => { + renderStatus({ + kind: 'status', + text: 'Codex stopped while this response was in progress.', + presentation: 'orca-stop', + tone: 'error' + }) + const line = screen.getByText( + 'This response was interrupted. You can continue in this conversation.' + ) + expect(line).toHaveClass('text-muted-foreground') + expect(line.closest('.text-destructive')).toBeNull() + }) it('renders future presentation and tone values as untinted text', () => { renderStatus({ kind: 'status', diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index a0864ebaadc3..2890220a5af8 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -28,7 +28,9 @@ const HOST_STATUS_WORDS: Record string translate( 'components.native-chat.notices.responseInterrupted', HOST_STATUS_COPY['response-interrupted'] - ) + ), + 'orca-stop': () => + translate('components.native-chat.notices.responseInterrupted', HOST_STATUS_COPY['orca-stop']) } export function NativeChatNoticeRow({ 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 8e9ad92c2957..d66b67914db1 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 @@ -7,6 +7,7 @@ // single place that answers "does this message take a slot?", and it answers it // with the same derivation the row itself renders from. +import { isAgentSessionInterruptionPresentation } from '../../../../shared/agent-session-host-status-rows' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { isBackgroundTaskBlock, @@ -127,11 +128,17 @@ export function buildNativeChatTranscriptSlots( outlivesTurn: message.blocks.some( (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) ), - reportsFailure: message.blocks.some((block) => block.type === 'text' && block.tone === 'error'), + reportsFailure: message.blocks.some( + (block) => + block.type === 'text' && + block.tone === 'error' && + !isAgentSessionInterruptionPresentation(block.presentation) + ), explainsTurn: message.blocks.some( (block) => block.type === 'text' && - (block.presentation === 'compaction' || block.presentation === 'response-interrupted') + (block.presentation === 'compaction' || + isAgentSessionInterruptionPresentation(block.presentation)) ) })) // Liveness is the turn's, not any one call's: the run at the frontier stays diff --git a/src/shared/agent-session-host-status-rows.ts b/src/shared/agent-session-host-status-rows.ts index 77bd15b4b13a..d15ef2a3283e 100644 --- a/src/shared/agent-session-host-status-rows.ts +++ b/src/shared/agent-session-host-status-rows.ts @@ -10,7 +10,10 @@ export const AGENT_SESSION_HOST_STATUS_COPY = { 'history-item-too-large': 'This part of the chat was too large to show.', /** A turn cut short with nobody asking (a crash, quit, restart or eviction, or an agent exit whose * own settle failed), worded for every cause because a reader can't tell them apart. */ - 'response-interrupted': 'This response was interrupted. You can continue in this conversation.' + 'response-interrupted': 'This response was interrupted. You can continue in this conversation.', + /** A host's row about Orca stopping under a turn (a quit, an update), which names its cause in + * `orcaStop` for a client that words it; this client says only that it was interrupted. */ + 'orca-stop': 'This response was interrupted. You can continue in this conversation.' } as const export type AgentSessionHostStatusPresentation = keyof typeof AGENT_SESSION_HOST_STATUS_COPY @@ -27,6 +30,12 @@ export function agentSessionResponseInterruptedBody(): AgentJournalPlainStatusIt return { ...agentSessionHostStatusBody('response-interrupted'), tone: 'notice' } } +/** A row that says a turn was cut short: muted, never folded with the turn's work, and never a + * failure report, whatever tone an older reader needs it to carry. */ +export function isAgentSessionInterruptionPresentation(presentation: string | undefined): boolean { + return presentation === 'response-interrupted' || presentation === 'orca-stop' +} + export function isAgentSessionHostStatusPresentation( presentation: string | undefined ): presentation is AgentSessionHostStatusPresentation { diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 67c4f718214d..f6b23ba008cb 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -249,6 +249,25 @@ describe('withNativeChatCutTurnNotices', () => { expect(withNativeChatCutTurnNotices(wordedItems)).toBe(wordedItems) }) + // A row that names why Orca stopped keeps its red words and exit fact for older clients; this one + // mutes it in the notice's words, keeping its presentation and cause for a client that words them. + it('mutes an orca-stop row, keeping its presentation and cause, and reads the turn interrupted', () => { + for (const id of ['stale-session:s:shutdown-3-gen', 'stale-session:s:death-3-2000']) { + const stored = { ...exitWords(), presentation: 'orca-stop', orcaStop: { cause: 'quit' } } + const items = [user('u1'), turn('t1', 'u1', CUT), hostRow(id, stored, inTurn('t1'))] + + expect(notices(items), id).toEqual([]) + expect(withNativeChatCutTurnNotices(items)[2]!.body, id).toEqual({ + kind: 'status', + text: NOTICE, + presentation: 'orca-stop', + tone: 'notice', + orcaStop: { cause: 'quit' } + }) + expect(structuredAgentTurnVerdictReader(items)(items[1]!), id).toBe('interruption') + } + }) + // A host from before failure facts wrote the same rows with only their words. it("counts an older host's exit row by its writer, with no exit fact", () => { for (const id of ['provider-exit:s:3:gen', 'stale-session:s:death-3-2000']) { diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index 9916147aadcd..d6ce21e0dd2b 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -82,27 +82,37 @@ function cutTurnNotice( const ownerDeathRowCache = new WeakMap() /** A host's row about an agent process gone from under a turn (a reopen's, or a quit's), in the - * notice's words when it is in the older red form: "the agent stopped", in error red, though the - * evidence only proves the process is gone, not who ended it. A row that names its presentation, - * and an early build's row that quotes the exit's detail untoned, are kept as written. */ + * notice's words and muted. Two shapes, and only these: an older host's red "the agent stopped" + * (no presentation, error red or the exit fact), whose evidence only proves the process is gone; + * and an `orca-stop` row, which stays red for clients that fold every other row and names its + * cause in fields a newer client words, so its presentation is kept. Every other stored field is + * kept; the failure fact goes with the words it was built from. Any other row is kept as written, + * as is an early build's untoned row that quotes the exit's detail. */ function ownerDeathRowAsInterruption(item: AgentJournalRenderItem): AgentJournalRenderItem { - if ( - cutTurnStopExplanation(item) !== 'owner-death' || - item.body.kind !== 'status' || - item.body.presentation !== undefined || - (item.body.tone !== 'error' && - readAgentSessionFailureFact(item.body.failure)?.kind !== 'providerExited') - ) { + if (cutTurnStopExplanation(item) !== 'owner-death' || item.body.kind !== 'status') { + return item + } + const { presentation, tone } = item.body + const orcaStop = presentation === 'orca-stop' + const legacy = + presentation === undefined && + (tone === 'error' || readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited') + if (!orcaStop && !legacy) { return item } const cached = ownerDeathRowCache.get(item) if (cached) { return cached } - // Only the words change, and the failure fact the old words were built from goes with them: a - // field a newer host stored beside them (why Orca stopped) is kept. const { failure: _failure, ...stored } = item.body - const reworded = { ...item, body: { ...stored, ...agentSessionResponseInterruptedBody() } } + const reworded = { + ...item, + body: { + ...stored, + ...agentSessionResponseInterruptedBody(), + ...(orcaStop ? { presentation } : {}) + } + } ownerDeathRowCache.set(item, reworded) return reworded } From dee7764e657905baf7b06d21cec8f4d9f0b4688c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:13:33 -0700 Subject: [PATCH 16/24] fix(native-chat): the reopen row is stored red so older clients still show it A client older than the interrupted presentation folds every row but an error one under a collapsed turn and derives no notice beside a reopen row, so the muted row this host wrote left a crash cut unexplained there until the turn was expanded. The host now stores the row red, with the neutral words and the `response-interrupted` presentation; the shared reader mutes any red row with that presentation, so this client, desktop and phone, draws it as before. Tests pin the phone path for older red rows, `orca-stop` rows and the stored red row, and an older client's fold. --- ...red-agent-session.cut-turn-notice.test.tsx | 55 ++++++++++++++++++- ...tured-agent-session-crash-turn-end.test.ts | 35 ++++++++++-- ...gent-session-dead-generation-settlement.ts | 7 ++- ...red-agent-session-surface-lifetime.test.ts | 4 +- ...veChatMessageList.cut-notice-fold.test.tsx | 6 +- src/shared/agent-session-host-status-rows.ts | 6 ++ .../native-chat-cut-turn-notice.test.ts | 22 ++++++++ src/shared/native-chat-cut-turn-notice.ts | 16 ++++-- 8 files changed, 131 insertions(+), 20 deletions(-) diff --git a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx index c7f39a899513..3d90daa1cfa0 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -1,6 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' @@ -78,7 +79,8 @@ describe('useMobileStructuredAgentSession transcript', () => { renderer = null }) - it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => { + /** The phone's session hook over a journal snapshot, as the transcript reads it. */ + async function mountWith(items: AgentJournalRenderItem[]) { let listener: ((value: unknown) => void) | null = null const client: Pick = { sendRequest: vi.fn(async (): Promise => ({ @@ -112,8 +114,12 @@ describe('useMobileStructuredAgentSession transcript', () => { renderer = create(createElement(Harness)) }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshot(items))) + return rendered + } - act(() => listener?.(snapshot(ITEMS))) + it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => { + const rendered = await mountWith(ITEMS) await vi.waitFor(() => expect(rendered.hook?.session.messages.at(-1)).toMatchObject({ @@ -130,4 +136,49 @@ describe('useMobileStructuredAgentSession transcript', () => { // The turn bars place rows by the same items, so the notice joins the cut turn. expect(rendered.hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE }) }) + + // The phone draws the text of a row whose presentation it does not word, and ignores tone, so the + // shared reader must hand it the interrupted words for an older host's red row and for a row + // naming why Orca stopped. + it.each([ + ["an older host's red reopen row", undefined], + ['a row naming why Orca stopped', 'orca-stop'], + ['the reopen row a newer host stores red', 'response-interrupted'] + ] as const)('shows %s in the interrupted words', async (_label, presentation) => { + const stopped = + 'Codex stopped while this response was in progress. You can continue in this conversation.' + const rendered = await mountWith([ + ...ITEMS, + { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'stale-session:session-1:death-3-2000' + }), + revision: 0, + sequence: 4, + observedAt: 4, + turnScope: SCOPE, + body: { + kind: 'status', + text: stopped, + tone: 'error', + ...(presentation ? { presentation } : {}) + } + } + ]) + + await vi.waitFor(() => + expect(rendered.hook?.session.messages.at(-1)).toMatchObject({ + role: 'system', + blocks: [ + { + text: 'This response was interrupted. You can continue in this conversation.', + presentation: presentation ?? 'response-interrupted', + tone: 'notice' + } + ] + }) + ) + expect(JSON.stringify(rendered.hook?.session.messages)).not.toContain(stopped) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index bed7b3d793b4..e3ce223ace2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -18,6 +18,10 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' +import { + nativeChatTurnFold, + type NativeChatTurnFoldRow +} from '../../../shared/native-chat-turn-fold' import { completedStructuredAgentTurnSeconds, selectStructuredAgentTurnTimings @@ -195,6 +199,28 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +/** Whether a client from before this rule hides `row` under the settled turn it explains: that + * client's fold, which keeps only the turn's answer (its last prose or error row) and a compaction + * report outside the fold. */ +function foldedByAnOlderClient(row: { tone?: string; presentation?: string }): boolean { + const base = { turnKey: 'turn-1', rendersProse: true, outlivesTurn: false } + const rows: NativeChatTurnFoldRow[] = [ + { ...base, role: 'user', reportsFailure: false, explainsTurn: false }, + { ...base, role: 'assistant', reportsFailure: false, explainsTurn: false }, + { + ...base, + role: 'system', + reportsFailure: row.tone === 'error', + explainsTurn: row.presentation === 'compaction' + } + ] + return nativeChatTurnFold({ + rows, + settledTurnKeys: new Set(['turn-1']), + expandedTurnKeys: new Set() + }).foldedRows.has(2) +} + describe('a turn a crash cut short mid-tool', () => { it('ends at the last renewal, not at the tool call the provider last reported', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) @@ -237,10 +263,11 @@ describe('a turn a crash cut short mid-tool', () => { tone: 'notice' } ]) - // The host writes it so, for a reader older than this rule too. - expect(items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : []))).toEqual( - statusRows - ) + // The host stores it red with the same words: a client older than the presentation folds every + // row but an error one under a collapsed turn, and must still show it. + const stored = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) + expect(stored).toEqual([{ ...statusRows[0], tone: 'error' }]) + expect(foldedByAnOlderClient(stored[0]!)).toBe(false) const [timing] = selectStructuredAgentTurnTimings(items).values() expect( describeNativeChatTurnStatus({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 28f8c151b181..c85cff03456e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -5,7 +5,7 @@ import { } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' -import { agentSessionResponseInterruptedBody } from '../../../shared/agent-session-host-status-rows' +import { agentSessionResponseInterruptedStoredBody } from '../../../shared/agent-session-host-status-rows' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -266,8 +266,9 @@ export async function settleStaleStructuredAgentSessionState(input: { // The death evidence is Orca's log text, never a sentence for a person, and it proves only that // the agent's process is gone, not who ended it: a quit's close and an agent exit whose own // settle failed both read `exit-observed`. So the row blames no one. Written, not left to the - // reader's derived notice, because a client older than that notice sees only this row. - body: agentSessionResponseInterruptedBody(), + // reader's derived notice, because a client older than that notice sees only this row, and + // stored red so a client older than its presentation does not fold it away. + body: agentSessionResponseInterruptedStoredBody(), turnScope: runningRootTurnScope(items) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index dcd2a28a5d1a..493bc4a1528b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -825,7 +825,7 @@ describe('an unexpected provider exit', () => { expect(acquire).toHaveBeenCalledTimes(2) // The new child's acquire settled the turn from the release's evidence: ended at the exit's // receipt. The evidence is Orca's log text, so the row says only that the response was - // interrupted, muted. + // interrupted; stored red for older clients, which this client mutes. const history = await host.history({ sessionId: SESSION, direction: 'tail' }) const items = history.ok ? history.page.items : [] expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( @@ -836,7 +836,7 @@ describe('an unexpected provider exit', () => { kind: 'status', text: 'This response was interrupted. You can continue in this conversation.', presentation: 'response-interrupted', - tone: 'notice' + tone: 'error' }) expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx index 710fb8a13caa..5e5e2327be16 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx @@ -4,7 +4,7 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { agentSessionResponseInterruptedBody } from '../../../../shared/agent-session-host-status-rows' +import { agentSessionResponseInterruptedStoredBody } from '../../../../shared/agent-session-host-status-rows' import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, @@ -120,8 +120,8 @@ describe('a collapsed turn that was cut short', () => { ['derived for a quit, on a host that states scopes', () => cutTurn(true)], ['derived for a quit, on a host that states none', () => cutTurn(false)], [ - "the host's reopen row", - () => [...cutTurn(true), reopenRow(agentSessionResponseInterruptedBody())] + "the host's reopen row, stored red for older clients", + () => [...cutTurn(true), reopenRow(agentSessionResponseInterruptedStoredBody())] ], [ "an older host's red reopen row, re-presented", diff --git a/src/shared/agent-session-host-status-rows.ts b/src/shared/agent-session-host-status-rows.ts index d15ef2a3283e..964c79fde6ec 100644 --- a/src/shared/agent-session-host-status-rows.ts +++ b/src/shared/agent-session-host-status-rows.ts @@ -30,6 +30,12 @@ export function agentSessionResponseInterruptedBody(): AgentJournalPlainStatusIt return { ...agentSessionHostStatusBody('response-interrupted'), tone: 'notice' } } +/** The same row as a host stores it: red, because a client older than the presentation folds every + * row but an error one under a collapsed turn. A client that knows the presentation mutes it. */ +export function agentSessionResponseInterruptedStoredBody(): AgentJournalPlainStatusItem { + return { ...agentSessionHostStatusBody('response-interrupted'), tone: 'error' } +} + /** A row that says a turn was cut short: muted, never folded with the turn's work, and never a * failure report, whatever tone an older reader needs it to carry. */ export function isAgentSessionInterruptionPresentation(presentation: string | undefined): boolean { diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index f6b23ba008cb..f8c03ec72af3 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -10,6 +10,7 @@ import type { AgentJournalTurnScope } from './agent-session-journal-types' import { structuredAgentTurnVerdictReader } from './native-chat-cut-turn-explanation' +import { agentSessionResponseInterruptedStoredBody } from './agent-session-host-status-rows' import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' import { hostStatesTurnScopes, nativeChatTurnMembership } from './native-chat-turn-membership' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' @@ -268,6 +269,27 @@ describe('withNativeChatCutTurnNotices', () => { } }) + // This host stores its reopen row red, for clients that fold every other row; this client mutes it. + it('mutes the reopen row this host stores red, keeping its presentation', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + hostRow( + 'stale-session:s:death-3-2000', + agentSessionResponseInterruptedStoredBody(), + inTurn('t1') + ) + ] + + expect(notices(items)).toEqual([]) + expect(withNativeChatCutTurnNotices(items)[2]!.body).toEqual({ + kind: 'status', + text: NOTICE, + presentation: 'response-interrupted', + tone: 'notice' + }) + }) + // A host from before failure facts wrote the same rows with only their words. it("counts an older host's exit row by its writer, with no exit fact", () => { for (const id of ['provider-exit:s:3:gen', 'stale-session:s:death-3-2000']) { diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts index d6ce21e0dd2b..f145ceb4e37e 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -6,7 +6,10 @@ // The journal names no cause a reader can see, so the words fit every cause and blame no one. import { readAgentSessionFailureFact } from './agent-session-failure' -import { agentSessionResponseInterruptedBody } from './agent-session-host-status-rows' +import { + agentSessionResponseInterruptedBody, + isAgentSessionInterruptionPresentation +} from './agent-session-host-status-rows' import { agentJournalItemKey } from './agent-session-journal-item-key' import type { AgentJournalRenderItem } from './agent-session-journal-types' import { readAgentJournalTurn } from './agent-session-turn-record' @@ -84,8 +87,9 @@ const ownerDeathRowCache = new WeakMap Date: Mon, 5 Oct 2026 15:14:40 -0700 Subject: [PATCH 17/24] test(native-chat): the older client's fold hides a muted reopen row, which is why it is stored red --- .../structured-agent-session-crash-turn-end.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index e3ce223ace2a..727ca6f1d98b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -268,6 +268,8 @@ describe('a turn a crash cut short mid-tool', () => { const stored = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) expect(stored).toEqual([{ ...statusRows[0], tone: 'error' }]) expect(foldedByAnOlderClient(stored[0]!)).toBe(false) + // Stored muted, as this rule first wrote it, that client hid it. + expect(foldedByAnOlderClient({ ...stored[0]!, tone: 'notice' })).toBe(true) const [timing] = selectStructuredAgentTurnTimings(items).values() expect( describeNativeChatTurnStatus({ From 1d72d47d011f49bf7e4ccf22a4b1df99be943786 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:41:37 -0700 Subject: [PATCH 18/24] fix(native-chat): a chat whose start failed keeps its Failed as any native chat's does The failed-start row #24904 writes carried no native-chat provenance, so its Failed faded from the tab and card after 30 minutes while every other native chat's Failed stays until the chat changes. It now carries the same marker ('held': no host runs its agent), and past the freshness window it sits in the card's retained tier like the rest. --- .../StructuredAgentSessionStatusBridge.tsx | 10 ++++++---- ...ent-session-status-bridge-failed-start.test.tsx | 14 +++++++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index c4f1844872f1..2dc2f3625612 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -109,7 +109,8 @@ function projectFailedStart(tab: StructuredTab, paneKey: string, failedAt: numbe current.agentType === tab.agentSessionAgent && current.terminalTitle === tab.label && current.tabId === tab.id && - current.worktreeId === tab.worktreeId + current.worktreeId === tab.worktreeId && + current.structuredHost === 'held' ) { return } @@ -128,11 +129,12 @@ function projectFailedStart(tab: StructuredTab, paneKey: string, failedAt: numbe sessionBoundary: false }, tab.label, - // Dated by the failure, as a host row is by its journal: it ages the same, a restart does not - // refresh it, and it replaces whatever newer-dated row the pane key held. + // Dated by the failure, as a host row is by its journal: a restart does not refresh it, and it + // replaces whatever newer-dated row the pane key held. { updatedAt: failedAt, allowOlderTimestamp: true, stateStartedAt: failedAt }, { tabId: tab.id, worktreeId: tab.worktreeId }, - { terminalResumeEligible: false } + // The chat's own settled state, kept as a host row's Failed is: no host runs its agent. + { terminalResumeEligible: false, structuredHost: 'held' } ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx index f647ed24415f..efc15b74c1f1 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx @@ -344,7 +344,10 @@ describe('a chat whose start failed', () => { expect(unread()).toBe(1) }) - it('ages like a host-reported failure: a restart does not bring back an old mark', async () => { + // A native chat's settled Failed is its state, so it stays until the chat changes, as a host's + // does; past the freshness window it yields to live work on the card, and a restart does not + // re-date it. + it('stays failed like a host-reported failure, past the freshness window and a restart', async () => { setClock(FAILED_AT) await connect() await failStart() @@ -353,10 +356,11 @@ describe('a chat whose start failed', () => { setClock(FAILED_AT + AGENT_STATUS_STALE_AFTER_MS + 1) await restart() expect(rows()).toEqual([expect.objectContaining({ updatedAt: FAILED_AT })]) - expect(tabStatus()).not.toBe('failed') - expect(selectWorktreeAgentActivitySummary(store().getState(), WORKTREE_ID).hasFailed).toBe( - false - ) + expect(tabStatus()).toBe('failed') + expect(selectWorktreeAgentActivitySummary(store().getState(), WORKTREE_ID)).toMatchObject({ + hasFailed: false, + hasRetainedFailed: true + }) }) it('dates a retry that fails again by the new failure', async () => { From 145ed2bfefd83791bc33f6bf25a1da0c09e1501c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:41:37 -0700 Subject: [PATCH 19/24] fix(native-chat): a departed agent's done does not take the card from a kept chat mark A departed agent's done never expires, and it outranked a native chat's kept Interrupted or "Couldn't confirm", so the card flipped to Done 30 minutes after the cut with no change in the chat. Kept marks now rank above a retained done and below only a fresh finish. --- .../worktree-agent-activity-summary.test.ts | 25 ++++++++++++++----- src/renderer/src/lib/worktree-status.ts | 8 ++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 961d96b6c335..797ec783dd3f 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -417,25 +417,38 @@ describe('selectWorktreeAgentActivitySummary', () => { updatedAt: at, stateStartedAt: at }) - const card = (...rows: AgentStatusEntry[]) => { + const card = ( + rows: AgentStatusEntry[], + retainedAgentsByPaneKey: AgentActivityInput['retainedAgentsByPaneKey'] = {} + ) => { vi.spyOn(Date, 'now').mockReturnValue(at) const summary = selectWorktreeAgentActivitySummary( { - tabsByWorktree: { [worktreeId]: [liveTab] }, + tabsByWorktree: { [worktreeId]: [liveTab, retainedTab] }, agentStatusEpoch: epoch++, agentStatusByPaneKey: Object.fromEntries(rows.map((row) => [row.paneKey, row])), migrationUnsupportedByPtyId: {}, runtimeAgentOrchestrationByPaneKey: {}, - retainedAgentsByPaneKey: {} + retainedAgentsByPaneKey }, worktreeId ) return resolveWorktreeStatus({ tabs: [], browserTabs: [], ptyIdsByTabId: {}, ...summary }) } - expect(card(fresh('working'), old('failure'))).toBe('working') - expect(card(fresh('done'), old('cancellation'))).toBe('done') - expect(card(old('interruption'))).toBe('interrupted') + expect(card([fresh('working'), old('failure')])).toBe('working') + expect(card([fresh('done'), old('cancellation')])).toBe('done') + expect(card([old('interruption')])).toBe('interrupted') + // A departed agent's done never expires either, so it does not take over once the chat's mark + // passes the freshness window: nothing in the chat changed. + const departedDone = { + 'tab-2:0': { + ...retainedFailure['tab-2:0'], + entry: makeAgentStatusEntry({ paneKey: 'tab-2:0', state: 'done' }) + } + } + expect(card([old('interruption')], departedDone)).toBe('interrupted') + expect(card([old('cancellation')], departedDone)).toBe('interrupted') }) it('reads a retained cut-short agent as interrupted, not done', () => { diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 340e4548a044..7e3778844e38 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -239,15 +239,19 @@ export function resolveWorktreeStatus(args: { if (args.hasInterrupted) { return 'interrupted' } - if (args.hasLiveDone || args.hasRetainedDone) { + if (args.hasLiveDone) { return 'done' } - // Why: an old end that never expires must not hide another agent's fresh finish. + // Why: a kept end yields to another agent's fresh finish, but not to a departed agent's done, + // which never expires either: the card would flip to Done on a timer with no change in the chat. if (args.hasRetainedUnconfirmed) { return 'unconfirmed' } if (args.hasRetainedInterrupted) { return 'interrupted' } + if (args.hasRetainedDone) { + return 'done' + } return heuristic } From 7fec698ace7e6c1616b924e01058690bc5fafe9e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:31:00 -0700 Subject: [PATCH 20/24] test(native-chat): drop the font-scale prop main removed from the message list --- .../native-chat/NativeChatMessageList.cut-notice-fold.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx index 5e5e2327be16..406712df0d7d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx @@ -104,7 +104,6 @@ function renderCollapsed(journal: readonly AgentJournalRenderItem[], represent = workingStartedAt={null} settledTurns={selectStructuredAgentSettledTurns(items)} expandSignal={false} - fontScale={1} /> ) expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( From e7820df7774ae3a514ee7fd13329b537c5ecceb3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:50:50 -0700 Subject: [PATCH 21/24] fix(test): drop the duplicate resolveLaunchArgs key so main's typecheck passes --- .../structured-agent-session-codex-stopped-send-order.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 2516e67782c5..3824f6edb6de 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -177,7 +177,6 @@ async function startHost(): Promise { resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - resolveLaunchArgs: () => [], openCodexConnection: openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) From b918ebb8ca9b8940bfee7a76837cc32ccc7bf5ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:47:45 -0700 Subject: [PATCH 22/24] fix(native-chat): rank a chat's settled mark on the worktree card the same at any age A native chat's Failed, Interrupted or Couldn't-confirm mark moved from the fresh tier to the retained tier 30 minutes after the turn ended, so the card could flip (Failed to Working, or Interrupted to Done) with nothing changed in any chat. The mark is the chat's state, so it now always sits in the retained tier: live work and another agent's fresh finish show over it, and alone it still shows. --- ...ession-status-bridge-failed-start.test.tsx | 11 +++++--- .../worktree-agent-activity-summary.test.ts | 27 +++++++++++-------- .../worktree-agent-activity-summary.ts | 22 ++++++++------- src/renderer/src/lib/worktree-status.ts | 4 +-- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx index efc15b74c1f1..a378281696ad 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx @@ -251,7 +251,9 @@ describe('a chat whose start failed', () => { }) expect(row && agentVerdictDisplayMark(row)).toBe('failed') expect(tabStatus()).toBe('failed') - expect(selectWorktreeAgentActivitySummary(store().getState(), WORKTREE_ID).hasFailed).toBe(true) + expect( + selectWorktreeAgentActivitySummary(store().getState(), WORKTREE_ID).hasRetainedFailed + ).toBe(true) }) it('drops the mark while a retry starts it, and leaves the started chat to the host', async () => { @@ -345,13 +347,16 @@ describe('a chat whose start failed', () => { }) // A native chat's settled Failed is its state, so it stays until the chat changes, as a host's - // does; past the freshness window it yields to live work on the card, and a restart does not - // re-date it. + // does; on the card it yields to live work at any age, and a restart does not re-date it. it('stays failed like a host-reported failure, past the freshness window and a restart', async () => { setClock(FAILED_AT) await connect() await failStart() expect(tabStatus()).toBe('failed') + expect(selectWorktreeAgentActivitySummary(store().getState(), WORKTREE_ID)).toMatchObject({ + hasFailed: false, + hasRetainedFailed: true + }) setClock(FAILED_AT + AGENT_STATUS_STALE_AFTER_MS + 1) await restart() diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 797ec783dd3f..3048fd3404fa 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -401,15 +401,21 @@ describe('selectWorktreeAgentActivitySummary', () => { ) }) - // A mark that never expires must not hide what another agent is doing or just finished. - it("shows live work and a fresh finish over a native chat's old verdict", () => { + // A mark that never expires must not hide what another agent is doing or just finished, and its + // rank must not change with its age: the card would flip on a timer with nothing changed. + it.each([ + ['an old', 1_000], + ['a fresh', 1_000 + AGENT_STATUS_STALE_AFTER_MS] + ])("shows live work and a fresh finish over a native chat's %s verdict", (_age, endedAt) => { const at = 1_000 + AGENT_STATUS_STALE_AFTER_MS + 60_000 - const old = (outcome: 'failure' | 'cancellation' | 'interruption'): AgentStatusEntry => ({ + const ended = (outcome: 'failure' | 'cancellation' | 'interruption'): AgentStatusEntry => ({ ...makeAgentStatusEntry({ paneKey: failedKey, state: 'done', - mainAgent: { state: 'done', outcome, stateStartedAt: 1_000 } + mainAgent: { state: 'done', outcome, stateStartedAt: endedAt } }), + updatedAt: endedAt, + stateStartedAt: endedAt, structuredHost: 'held' }) const fresh = (state: 'working' | 'done'): AgentStatusEntry => ({ @@ -436,19 +442,18 @@ describe('selectWorktreeAgentActivitySummary', () => { return resolveWorktreeStatus({ tabs: [], browserTabs: [], ptyIdsByTabId: {}, ...summary }) } - expect(card([fresh('working'), old('failure')])).toBe('working') - expect(card([fresh('done'), old('cancellation')])).toBe('done') - expect(card([old('interruption')])).toBe('interrupted') - // A departed agent's done never expires either, so it does not take over once the chat's mark - // passes the freshness window: nothing in the chat changed. + expect(card([fresh('working'), ended('failure')])).toBe('working') + expect(card([fresh('done'), ended('cancellation')])).toBe('done') + expect(card([ended('interruption')])).toBe('interrupted') + // A departed agent's done never expires either, so it does not take over the chat's mark. const departedDone = { 'tab-2:0': { ...retainedFailure['tab-2:0'], entry: makeAgentStatusEntry({ paneKey: 'tab-2:0', state: 'done' }) } } - expect(card([old('interruption')], departedDone)).toBe('interrupted') - expect(card([old('cancellation')], departedDone)).toBe('interrupted') + expect(card([ended('interruption')], departedDone)).toBe('interrupted') + expect(card([ended('cancellation')], departedDone)).toBe('interrupted') }) it('reads a retained cut-short agent as interrupted, not done', () => { diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 17446972a19a..82b1b3c12266 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -25,9 +25,8 @@ export type WorktreeAgentActivitySummary = { hasUnconfirmed: boolean hasLiveDone: boolean hasRetainedDone: boolean - /** A failure with no expiry (a departed agent's, or a native chat's kept past the freshness - * window); unlike `hasFailed` it yields to live work. The retained marks below yield to a - * fresh finish too. */ + /** A failure with no expiry (a departed agent's, or a native chat's settled verdict); unlike + * `hasFailed` it yields to live work. The retained marks below yield to a fresh finish too. */ hasRetainedFailed: boolean hasRetainedInterrupted: boolean hasRetainedUnconfirmed: boolean @@ -140,7 +139,8 @@ function getWorktreeAgentActivitySummaries( continue } const fresh = isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) - if (!fresh && !isSettledNativeChatVerdict(entry)) { + const settledNativeChat = isSettledNativeChatVerdict(entry) + if (!fresh && !settledNativeChat) { // Why: staleness ends this row's authority but not the pane's identity — see // `stalePaneIdsByTabId`. Dropping both let Orca's self-authored permission title outlive // the row it came from and pin the card to a question nobody was asking. @@ -151,10 +151,12 @@ function getWorktreeAgentActivitySummaries( if (entry.state === 'done') { addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) } - if (fresh) { - applyAgentPaneActivityFlags(summary, entry) - } else { + // Why: a native chat's settled mark ranks the same at any age; a tier change at the freshness + // window would flip the card on a timer with no change in the chat. + if (settledNativeChat) { applyRetainedAgentMark(summary, entry) + } else { + applyAgentPaneActivityFlags(summary, entry) } } @@ -203,9 +205,9 @@ function getWorktreeAgentActivitySummaries( return summaries } -/** A mark with no expiry: a departed agent's, or a native chat's settled verdict past the freshness - * window. It keeps showing, but in the retained tier, so live work and a fresh finish show over it; - * a failed or cut-short agent is retained so that stays visible, not so it reads done. */ +/** A mark with no expiry: a departed agent's, or a native chat's settled verdict at any age. It keeps + * showing, but in the retained tier, so live work and a fresh finish show over it; a failed or + * cut-short agent is retained so that stays visible, not so it reads done. */ function applyRetainedAgentMark( summary: WorktreeAgentActivitySummary, entry: Parameters[0] diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 7e3778844e38..7efc5fbe536f 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -226,8 +226,8 @@ export function resolveWorktreeStatus(args: { if (args.hasLiveMonitoring || heuristic === 'monitoring') { return 'monitoring' } - // Why: a failure with no expiry (a departed agent's, an old native chat's) must not pin the card - // over live work. + // Why: a failure with no expiry (a departed agent's, a native chat's) must not pin the card over + // live work. if (args.hasRetainedFailed) { return 'failed' } From 7eb335f77486505bd723445f1b79b7f69167424d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:50:04 -0700 Subject: [PATCH 23/24] test(acp): read Grok's cut-turn transcript with the agent-neutral notice This branch's derived cut-turn notice names no agent, so it takes no options; the Grok tests from main passed `{ agentName: 'Grok' }`. Grok's own exit row keeps "Grok stopped..." from the host. --- src/main/acp/acp-structured-host-lifecycle.test.ts | 4 ++-- src/main/acp/acp-structured-host-stop.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index a1486cd3263e..4f1bde6a7182 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -102,7 +102,7 @@ describe('reopening a Grok chat through the host', () => { 'completed' ) // The transcript explains the cut the way a Claude or Codex chat's does. - const transcript = withNativeChatCutTurnNotices(rows, { agentName: 'Grok' }) + const transcript = withNativeChatCutTurnNotices(rows) expect( transcript.some( (row) => @@ -150,7 +150,7 @@ describe('a Grok crash whose exit is not proven yet', () => { child.exit() await rig.settle() await host.flushStreamedEvents(SESSION) - const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + const transcript = withNativeChatCutTurnNotices(await rows()) // The crash's own notice, as for any crash; not a Stop's. expect( transcript.flatMap((row) => diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts index 2e6677390d65..c240e9478d4e 100644 --- a/src/main/acp/acp-structured-host-stop.test.ts +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -57,7 +57,7 @@ describe('a Grok chat Stop', () => { expect(framesOf(second, 'session/new')).toHaveLength(0) expect(await messages()).toEqual(['hello', 'partial', 'again']) // Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for. - const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) + const transcript = withNativeChatCutTurnNotices(await rows()) expect( transcript.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : [])) ).toEqual(['Cancellation requested.']) From cdcc64621d51077564e8fd3324b4ca7722ad2d0a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:15:09 -0700 Subject: [PATCH 24/24] test(native-chat): a quit's stored cut row is red; the reader mutes it Main's #25675 now writes the quit's own row about a cut turn, and these restart tests read it twice: as stored and as the transcript shows it. The host stores it red so an older client keeps it on screen, and this branch's reader shows it muted, so the two views differ only in that tone. The stored-row assertions now expect the red row; the reader assertions keep the muted notice. --- ...ured-agent-session-restart-failure-filing.test.ts | 12 ++++++++---- ...gent-session-restart-interruption-test-harness.ts | 4 ++++ ...ed-agent-session-restart-offer-withdrawal.test.ts | 4 ++-- ...tructured-agent-session-restart-ownership.test.ts | 4 ++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index c99cc945d31c..fbd1abbf601f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -9,6 +9,7 @@ import { StructuredAgentSessionResumeAdmission } from './structured-agent-sessio import { interruptedRestart, QUIT_CUT_NOTICE, + QUIT_CUT_ROW, readerNotes, statusNotes, throwAfterContinuationAccepted @@ -46,7 +47,7 @@ it('files nothing for a chat the user moved on in before its attempt, and spends expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) // Only the quit's own row about the cut: no note took over, so it stays the turn's one notice. - expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE]) + expect(await statusNotes(host)).toEqual([QUIT_CUT_ROW]) expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) }) @@ -64,12 +65,15 @@ it('says so in the chat when the agent cannot start for the continuation', async expect(await host.restartResume.listFailures()).toMatchObject([ { sessionId: SESSION, outcome: 'refused', retryable: true } ]) - expect(await statusNotes(host)).toContainEqual({ + const stored = await statusNotes(host) + expect(stored).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, tone: 'error' }) - // The refusal note is now the one explanation: the cut is not said a second time beside it. - expect(await readerNotes(host)).toEqual(await statusNotes(host)) + // The refusal note is now the one explanation: the cut is not said a second time beside it. The + // reader only mutes the quit's own row, which is stored red for older clients. + expect(stored[0]).toEqual(QUIT_CUT_ROW) + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE, ...stored.slice(1)]) }) // A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 7edc93280ed7..574e36bd673e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -224,6 +224,10 @@ export const QUIT_CUT_NOTICE = { tone: 'notice' } +/** The quit's own row about the cut as the host stores it: red, so an older client keeps it on + * screen; a reader shows it as `QUIT_CUT_NOTICE`. */ +export const QUIT_CUT_ROW = { ...QUIT_CUT_NOTICE, tone: 'error' } + /** A continuation the host refuses because the user's own message was accepted first: another * client's send lands after the action reserved the offer, just before the continuation is * accepted. `userAnswers` instead has the user send before or after the whole attempt. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index 89e039689a5f..120394fad0e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -17,7 +17,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { interruptedRestart, - QUIT_CUT_NOTICE, + QUIT_CUT_ROW, statusNotes } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' @@ -387,7 +387,7 @@ it("refuses an automatic continuation when the user's message was accepted first await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) expect(sentTexts(dispatch)).toEqual(['A new request']) // Nothing the user did failed: no row but the quit's own about the cut, and nothing is kept. - expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE]) + expect(await statusNotes(host)).toEqual([QUIT_CUT_ROW]) expect( events.some( (event) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 0d73656b3477..eb9be2f603de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -16,7 +16,7 @@ import { import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, - QUIT_CUT_NOTICE, + QUIT_CUT_ROW, startAgent, statusNotes, supersededRefusal, @@ -359,7 +359,7 @@ it("refuses a continuation quietly when the user's own message was accepted firs }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) // The quit's own row about the cut, and nothing about the refused continuation. - expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE]) + expect(await statusNotes(host)).toEqual([QUIT_CUT_ROW]) expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) })