diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index 2506ab6e311c..adb002731d3a 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -75,15 +75,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/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index 9d75bb02d7d6..a9342c522643 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -24,7 +24,6 @@ import { type StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing' -import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { buildMobileNativeChatTransientData, foldMobileNativeChatMessages @@ -55,9 +54,7 @@ function apply( /** The rows the phone's transcript reads, as its session hook builds them: the journal plus the * notice a cut turn gets, projected with no other rejected send drawn in place. */ function phoneTranscript(state: StructuredAgentSessionState) { - const items = withNativeChatCutTurnNotices(state.items, { - agentName: TUI_AGENT_DISPLAY_NAMES.codex - }) + const items = withNativeChatCutTurnNotices(state.items) return { items, messages: projectStructuredAgentSessionMessages(items, [], state.submissions, { 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..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,16 +114,21 @@ 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({ 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' } ] }) @@ -129,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/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index bb52186a4b1c..9866562d81eb 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 { @@ -175,13 +174,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/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 89fef0a476a7..5637ca701a47 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 ef17ab39895b..758493275e11 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/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.']) 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 e801a99042da..3b82de3554ca 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 @@ -23,7 +23,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, @@ -221,8 +220,7 @@ async function runAttachUnderAbort( 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 a027d9b1caf7..cf03862bc150 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 @@ -125,17 +125,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 like a finished one, with exactly one row saying 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) @@ -166,8 +166,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) }) @@ -261,7 +261,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-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index ec7f478501f7..d7aa69d7967a 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 @@ -23,7 +23,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 { markStructuredQueueReopen, structuredAgentSessionHostInstance @@ -158,8 +157,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 a36518c5e614..1916f9b6e670 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 @@ -200,6 +204,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, draws: 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' }) }) @@ -222,7 +248,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' }) }) @@ -230,16 +256,25 @@ describe('a turn a crash cut short mid-tool', () => { 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] : []) + // 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] : [] ) 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 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) + // 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({ @@ -247,7 +282,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 () => { @@ -332,7 +367,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' }), @@ -348,7 +383,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 @@ -359,7 +395,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('explains the turn its proof revised, so the cut reads once and offers Continue', async () => { @@ -391,7 +427,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () { kind: 'turn', turnItemId } ]) expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' }) - const readerRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).filter( + const readerRows = withNativeChatCutTurnNotices(items).filter( (item) => item.body.kind === 'status' ) expect(readerRows).toHaveLength(1) 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 f3c8c5df9fea..c6a1e581035b 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 @@ -187,8 +187,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 @@ -242,9 +240,13 @@ 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, and how Orca ended when the provider died with it. - body: orcaStopRowBody(input.failureTextContext, evidence.runtimeEnd), + // 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, and names how Orca ended + // when the provider died with it. Written, not left to the 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: orcaStopRowBody(evidence.runtimeEnd), turnScope: settledRootTurnScope(items, turnEnds) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 8e072e714b6d..798163443d5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -34,7 +34,6 @@ import { recordStructuredAgentSessionShutdownCut, runningRootTurnItemId } from './structured-agent-session-orca-stop-row' -import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { recordStopEvent, @@ -190,7 +189,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( } // The exit handler has settled the turn by now, so the row reads its verdict. if ('quit' in ending && ending.quit) { - const record = context.deps.store.getRecord(sessionId) await recordStructuredAgentSessionShutdownCut({ journal: session.journal, sessionId, @@ -198,9 +196,6 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( generation: child.generation ?? 'unknown', turnItemId: quitCuts, trigger: ending.quit, - ...(record - ? { failureTextContext: structuredAgentSessionFailureWordsContext(record) } - : {}), logger: context.deps.logger }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts index 74b5abe1bc31..b8d4a22d074a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts @@ -1,7 +1,8 @@ // The row about Orca's own stop, as each kind of client reads it. A client that predates the cause -// prints today's row, red and on screen; this build names the cause, muted, and never folds it. +// prints the stored row, red and on screen; this build names the cause, muted, and never folds it. import { describe, expect, it } from 'vitest' +import { agentSessionResponseInterruptedStoredBody } from '../../../shared/agent-session-host-status-rows' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' @@ -9,8 +10,7 @@ import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-st import { nativeChatTurnFold } from '../../../shared/native-chat-turn-fold' import { orcaStopRowBody } from './structured-agent-session-orca-stop-row' -const LEGACY_TEXT = - 'Codex stopped while this response was in progress. You can continue in this conversation.' +const INTERRUPTED_TEXT = 'This response was interrupted. You can continue in this conversation.' const TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 }) function cutChat(row: AgentJournalRenderItem['body']): AgentJournalRenderItem[] { @@ -53,29 +53,28 @@ function cutChat(row: AgentJournalRenderItem['body']): AgentJournalRenderItem[] } describe('the row a host writes for a cut it knows the cause of', () => { - const row = orcaStopRowBody({ agentName: 'Codex' }, 'update') + const row = orcaStopRowBody('update') - it("keeps today's words and tone, and names its presentation and cause beside them", () => { + it('blames no one, stays red, and names its presentation and cause', () => { expect(row).toMatchObject({ kind: 'status', - text: LEGACY_TEXT, + text: INTERRUPTED_TEXT, tone: 'error', - failure: { kind: 'providerExited' }, presentation: 'orca-stop', orcaStop: { cause: 'update' } }) + expect(row).not.toHaveProperty('failure') }) - it('reads, on a client that predates the cause, as the one row it prints today, on screen', () => { + it('reads, on a client that predates the cause, as the one row it prints, on screen', () => { + // That client derives no row of its own beside a host's stale-session row. const items = cutChat(row) - const read = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) - expect(read).toBe(items) - expect(read.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : []))).toEqual([ - LEGACY_TEXT + expect(items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : []))).toEqual([ + INTERRUPTED_TEXT ]) // That client's fold reads only the stored tone: a red row is the turn's end, never folded. - const rows = read.map((item) => ({ - turnKey: item.itemId === read[0]!.itemId ? undefined : TURN, + const rows = items.map((item) => ({ + turnKey: item.itemId === items[0]!.itemId ? undefined : TURN, role: item.body.kind === 'message' ? item.body.role : ('system' as const), rendersProse: item.body.kind !== 'turn', draws: true, @@ -91,6 +90,17 @@ describe('the row a host writes for a cut it knows the cause of', () => { expect(foldedRows.has(3)).toBe(false) }) + it('reads, on this build, as one muted row that keeps its cause', () => { + const read = withNativeChatCutTurnNotices(cutChat(row)) + const statusRows = read.filter((item) => item.body.kind === 'status') + expect(statusRows).toHaveLength(1) + expect(statusRows[0]!.body).toMatchObject({ + tone: 'notice', + presentation: 'orca-stop', + orcaStop: { cause: 'update' } + }) + }) + it('offers Continue on this build', () => { expect(latestNativeChatOrcaStopCut(cutChat(row), [])).toEqual({ turnItemId: TURN, @@ -98,7 +108,7 @@ describe('the row a host writes for a cut it knows the cause of', () => { }) }) - it('names no presentation when the cause is unknown, so it reads as any owner death', () => { - expect(orcaStopRowBody({ agentName: 'Codex' }, undefined)).not.toHaveProperty('presentation') + it('is the row for any owner death when the cause is unknown', () => { + expect(orcaStopRowBody(undefined)).toEqual(agentSessionResponseInterruptedStoredBody()) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts index 1778f7e43977..7f69af005d04 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts @@ -2,16 +2,12 @@ // every client already prints for a stopped agent, with the cause beside them for clients that // name it (`AgentSessionOrcaStop`). -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { - AGENT_SESSION_ORCA_STOP_PRESENTATION, - isAgentSessionOrcaStopCause -} from '../../../shared/agent-session-orca-stop' + agentSessionHostStatusBody, + agentSessionResponseInterruptedStoredBody +} from '../../../shared/agent-session-host-status-rows' +import { isAgentSessionOrcaStopCause } from '../../../shared/agent-session-orca-stop' import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' import type { @@ -29,24 +25,13 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo type OrcaStopRowJournal = Pick -/** The row about an owner gone from under a turn: today's words and tone, which every client prints - * as it always has, and why Orca stopped when it was Orca, which a client that knows the cause names - * instead, muted. */ -export function orcaStopRowBody( - context: AgentSessionFailureWordsContext | undefined, - orcaEnd: unknown -): AgentJournalStatusItem { - return { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { - ...context, - surface: 'row' - }), - tone: 'error', - ...(isAgentSessionOrcaStopCause(orcaEnd) - ? { presentation: AGENT_SESSION_ORCA_STOP_PRESENTATION, orcaStop: { cause: orcaEnd } } - : {}) - } +/** The row about an owner gone from under a turn: words that blame no one, stored red so an older + * client keeps it on screen, and why Orca stopped when it was Orca, which a client that knows the + * cause names instead, muted. */ +export function orcaStopRowBody(orcaEnd: unknown): AgentJournalStatusItem { + return isAgentSessionOrcaStopCause(orcaEnd) + ? { ...agentSessionHostStatusBody('orca-stop'), tone: 'error', orcaStop: { cause: orcaEnd } } + : agentSessionResponseInterruptedStoredBody() } /** The root turn running as the quit stops the child: the one its stop may cut. */ @@ -84,7 +69,6 @@ export async function recordStructuredAgentSessionShutdownCut(input: { generation: string turnItemId: string | null trigger: AgentSessionResumeTrigger - failureTextContext?: AgentSessionFailureWordsContext logger: StructuredAgentSessionLogger }): Promise { try { @@ -107,7 +91,7 @@ export async function recordStructuredAgentSessionShutdownCut(input: { } await input.journal.appendItem( { provider: 'orca', clientMessageId }, - orcaStopRowBody(input.failureTextContext, input.trigger), + orcaStopRowBody(input.trigger), { fence: input.fence, turnScope: { kind: 'turn', turnItemId } } ) } catch (error) { 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 8c98a685c7d2..86cae4dab8b2 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-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 6dfcb4f898e9..61184e82a9e1 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 @@ -143,16 +143,16 @@ 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 the user did not stop is a fault, marked as a failure is. + // 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' | 'failure', 'unconfirmed' | 'failed' ][])( 'is done as of the recovery with the end the host observed, never a success: %s', @@ -184,13 +184,25 @@ 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', + // 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', @@ -209,7 +221,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) @@ -217,16 +229,37 @@ 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' ) } ) + // 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/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 e17315b43e83..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 @@ -209,11 +209,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[]) { @@ -222,12 +218,16 @@ 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' } +/** 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([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts index 5c82a2185a51..f56f6cf86c24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts @@ -36,8 +36,7 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } -const LEGACY_TEXT = - 'Codex stopped while this response was in progress. You can continue in this conversation.' +const INTERRUPTED_TEXT = 'This response was interrupted. You can continue in this conversation.' let host: StructuredAgentSessionHost /** How the provider ends its open turn as it is stopped. */ @@ -129,10 +128,10 @@ async function reread() { (item) => item.body.kind === 'status' && readAgentSessionOrcaStop(item.body.orcaStop) !== undefined ) - const readerErrors = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap( - (item) => (item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : []) + const readerRows = withNativeChatCutTurnNotices(items).flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] ) - return { items, turnItemId, stopRows, readerErrors } + return { items, turnItemId, stopRows, readerRows } } describe('the row a quit writes for the reply it cut', () => { @@ -141,20 +140,21 @@ describe('the row a quit writes for the reply it cut', () => { await host.flushAllStreamedEvents({ trigger }) - const { items, turnItemId, stopRows, readerErrors } = await reread() + const { items, turnItemId, stopRows, readerRows } = await reread() expect(stopRows).toHaveLength(1) const [row] = stopRows + // Stored red, so a client that reads no cause keeps it on screen; it blames no one. expect(row?.body).toMatchObject({ kind: 'status', - text: LEGACY_TEXT, + text: INTERRUPTED_TEXT, tone: 'error', - failure: { kind: 'providerExited' }, presentation: 'orca-stop', orcaStop: { cause: trigger } }) + expect(row?.body).not.toHaveProperty('failure') expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId }) - // A client that reads no cause still prints exactly one row for the cut, in today's words. - expect(readerErrors).toEqual([LEGACY_TEXT]) + // The reader prints exactly one row for the cut. + expect(readerRows).toEqual([INTERRUPTED_TEXT]) expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: trigger }) }) @@ -164,9 +164,9 @@ describe('the row a quit writes for the reply it cut', () => { await host.flushAllStreamedEvents({ trigger: 'update' }) - const { stopRows, readerErrors } = await reread() + const { stopRows, readerRows } = await reread() expect(stopRows).toEqual([]) - expect(readerErrors).toEqual([]) + expect(readerRows).toEqual([]) }) it("writes nothing for a turn a person's Stop already ended", async () => { @@ -184,10 +184,10 @@ describe('the row a quit writes for the reply it cut', () => { await host.close(SESSION, 'evict') - const { items, stopRows, readerErrors } = await reread() + const { items, stopRows, readerRows } = await reread() expect(stopRows).toEqual([]) - // The cut keeps today's derived notice, and offers no Continue. - expect(readerErrors).toEqual([LEGACY_TEXT]) + // The cut keeps its derived notice, and offers no Continue. + expect(readerRows).toEqual([INTERRUPTED_TEXT]) expect(latestNativeChatOrcaStopCut(items, [])).toBeNull() }) }) @@ -198,13 +198,13 @@ describe('the row a restart writes for a reply its Orca died in', () => { await restartAfterDeath() - const { items, turnItemId, stopRows, readerErrors } = await reread() + const { items, turnItemId, stopRows, readerRows } = await reread() expect(stopRows).toHaveLength(1) expect(stopRows[0]?.body).toMatchObject({ - text: LEGACY_TEXT, + text: INTERRUPTED_TEXT, orcaStop: { cause: 'crash' } }) - expect(readerErrors).toEqual([LEGACY_TEXT]) + expect(readerRows).toEqual([INTERRUPTED_TEXT]) expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' }) }) @@ -225,10 +225,10 @@ describe('the row a restart writes for a reply its Orca died in', () => { await restartAfterDeath() - const { stopRows, readerErrors } = await reread() + const { stopRows, readerRows } = await reread() expect(stopRows).toEqual([]) - // The cut keeps today's words. - expect(readerErrors).toEqual([LEGACY_TEXT]) + // The cut keeps the words for any interruption. + expect(readerRows).toEqual([INTERRUPTED_TEXT]) }) }) 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 90e5e84cf861..785bf4f3adf7 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 @@ -469,9 +469,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 55eb9080a5b1..2b853ca15b03 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' @@ -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,27 +172,27 @@ 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([]) }) - // 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() - 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(/^Worked for /) - expect(mark).toBe('failed') - expect(errorRows).toEqual([ - expect.stringContaining('stopped while this response was in progress') + expect(label).toMatch(/^Interrupted after /) + expect(mark).toBe('interrupted') + expect(stopRows).toEqual([ + 'This response was interrupted. You can continue in this conversation.' ]) }) @@ -209,7 +211,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 +252,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, stopRows } = settled(NEXT_TURN) expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(mark).toBe('failed') + expect(mark).toBe('interrupted') + expect(stopRows).toEqual([ + 'This response was interrupted. You can continue in this conversation.' + ]) }) // Claude's Stop ends its child whatever the interrupt answered. 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 d0b286b24b91..0ed71e6cb304 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 @@ -827,7 +827,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; 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,8 +837,8 @@ 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' }, + text: 'This response was interrupted. You can continue in this conversation.', + presentation: 'response-interrupted', tone: 'error' }) 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 768b6724caa2..8af6c6f10587 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 @@ -352,11 +352,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/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' + } + ]) }) }) 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 9a53c4ada07c..0c1bf83c502a 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts @@ -49,8 +49,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 5c60c422fe9f..ec1fdaf1662a 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 @@ -54,13 +54,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 0631251d8492..b6700da3196e 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.cut-notice-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx new file mode 100644 index 000000000000..406712df0d7d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.cut-notice-fold.test.tsx @@ -0,0 +1,188 @@ +// @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 { agentSessionResponseInterruptedStoredBody } 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[], represent = true) { + const items = represent ? withNativeChatCutTurnNotices(journal) : 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, stored red for older clients", + () => [...cutTurn(true), reopenRow(agentSessionResponseInterruptedStoredBody())] + ], + [ + "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' + }) + ] + ], + [ + '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()) + + expect(screen.getByText('partial answer a2')).toBeInTheDocument() + expect(screen.queryByText('narration a1')).toBeNull() + 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), + 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/NativeChatMessageList.interrupted-turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.interrupted-turn-fold.test.tsx index f9d2ee5e3a14..f7ca2f296a95 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/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index db62bc280a7e..e9498de447ca 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -44,6 +44,7 @@ function renderStatus( const LEGACY_TEXT = 'Codex stopped while this response was in progress. You can continue in this conversation.' +const INTERRUPTED_TEXT = 'This response was interrupted. You can continue in this conversation.' function orcaStopRow(cause: string): AgentJournalStatusItem { return { @@ -81,7 +82,7 @@ describe('the row an Orca stop leaves', () => { // Its words and tone replaced, and the failure fact the old words came from dropped with them. const represented: AgentJournalStatusItem = { kind: 'status', - text: 'This response was interrupted. You can continue in this conversation.', + text: INTERRUPTED_TEXT, tone: 'notice', presentation: 'orca-stop', orcaStop: { cause: 'crash' } @@ -94,22 +95,18 @@ describe('the row an Orca stop leaves', () => { ).toHaveClass('text-muted-foreground') cleanup() renderStatus(represented, null, true) - expect( - screen.getByText('This response was interrupted. You can continue in this conversation.') - .parentElement?.parentElement - ).toHaveClass('text-muted-foreground') + expect(screen.getByText(INTERRUPTED_TEXT)).toHaveClass('text-muted-foreground') }) - it('keeps the host words for a cause this build does not know', () => { + it('reads as any interrupted response for a cause this build does not know', () => { renderStatus(orcaStopRow('power-loss'), 'studio-mac') - expect(screen.getByText(LEGACY_TEXT)).toBeInTheDocument() + expect(screen.getByText(INTERRUPTED_TEXT)).toBeInTheDocument() + expect(screen.queryByText(LEGACY_TEXT)).toBeNull() }) - it('keeps the host words when the chat has no machine to name, muted all the same', () => { + it('reads as any interrupted response when the chat has no machine to name, muted', () => { renderStatus(orcaStopRow('update'), null) - expect(screen.getByText(LEGACY_TEXT).parentElement?.parentElement).toHaveClass( - 'text-muted-foreground' - ) + expect(screen.getByText(INTERRUPTED_TEXT)).toHaveClass('text-muted-foreground') }) it('keeps the host words for a stop Orca did not cause', () => { @@ -190,10 +187,43 @@ 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() + }) + // 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() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 47856cdaae84..501cf9336eff 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -13,7 +13,6 @@ import { import type { NativeChatTextBlock } from '../../../../shared/native-chat-types' import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' -import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' import { ProviderFrameRow } from './NativeChatTranscriptChrome' import { Button } from '@/components/ui/button' import { @@ -32,7 +31,14 @@ 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'] + ), + 'orca-stop': () => + translate('components.native-chat.notices.responseInterrupted', HOST_STATUS_COPY['orca-stop']) } export function NativeChatNoticeRow({ @@ -44,8 +50,12 @@ export function NativeChatNoticeRow({ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean }): React.JSX.Element { - const orcaStopView = useNativeChatOrcaStopView() + const { hostLabel, continueAvailable } = useNativeChatOrcaStopView() const claudeSignIn = useNativeChatClaudeSignInView() + const { orcaStop } = block + // The host's row about an Orca stop names the cause and the machine, muted: Orca stopped, not the + // agent. With no machine to name it reads as any other interrupted response. + const named = orcaStop !== undefined && hostLabel !== null if (block.presentation === 'compaction') { const label = translate('components.native-chat.notices.compaction', 'Context compacted') return ( @@ -68,7 +78,7 @@ export function NativeChatNoticeRow({ ) } - if (isAgentSessionHostStatusPresentation(block.presentation)) { + if (!named && isAgentSessionHostStatusPresentation(block.presentation)) { // The look of any other host status line; only the words are the reader's. return (

@@ -98,16 +108,10 @@ export function NativeChatNoticeRow({ ) } - // The host's row about an Orca stop names the cause and the machine, muted: Orca stopped, not the - // agent. With no machine to name it keeps the host's own words. - const { orcaStop } = block - const { hostLabel, continueAvailable } = orcaStopView - const named = orcaStop !== undefined && hostLabel !== null const text = named ? nativeChatOrcaStopRowText(orcaStop.cause, hostLabel, { continueAvailable }) : block.text - const tone = - named || block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ? 'notice' : block.tone + const tone = named ? 'notice' : block.tone const Icon = tone === 'warning' ? AlertTriangle diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index beeb27600cbb..e4d37cf0374b 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' @@ -207,6 +207,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', structuredHost: 'held' }) + 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 56e93c60e602..cfd6ff2e738d 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -148,7 +148,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 } @@ -167,11 +168,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' } ) } @@ -202,6 +204,8 @@ function projectStatus( ...(summary.stopping ? { stopping: summary.stopping } : {}) }) 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, @@ -248,6 +252,7 @@ function projectStatus( current.worktreeId === tab.worktreeId && current.terminalResumeEligible === false && current.structuredHostOwned === summary.hostExecutionOwned && + current.structuredHost === structuredHost && agentProviderSessionsEqual( tab.agentSessionAgent, current.providerSession, @@ -281,6 +286,7 @@ function projectStatus( { ...(summary.providerSession ? { providerSession: summary.providerSession } : {}), terminalResumeEligible: false, + structuredHost, ...(summary.hostExecutionOwned ? { structuredHostOwned: true as const } : {}) } ) 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 ebae5d79e213..246022b31562 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,11 +7,11 @@ // 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, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' import { isBackgroundTaskBlock, isSubagentGroupBlock, @@ -152,18 +152,19 @@ export function buildNativeChatTranscriptSlots( isBackgroundTaskBlock(block) || isStoppedBeforeStartBlock(block) ), - // A row about Orca's own stop is stored red for clients that predate it; it reports no failure. + // A row that says a turn was cut short is stored red for clients that predate it; it reports no + // failure. reportsFailure: message.blocks.some( (block) => block.type === 'text' && block.tone === 'error' && - block.presentation !== AGENT_SESSION_ORCA_STOP_PRESENTATION + !isAgentSessionInterruptionPresentation(block.presentation) ), explainsTurn: message.blocks.some( (block) => block.type === 'text' && (block.presentation === 'compaction' || - block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION) + isAgentSessionInterruptionPresentation(block.presentation)) ) })) // Liveness is the turn's, not any one call's: the run at the frontier stays 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..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 () => { @@ -344,19 +346,26 @@ 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; 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() 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 () => { 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 f4cd4c927ad7..30f691aaadea 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 @@ -88,8 +88,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' } ] }) @@ -97,7 +98,9 @@ describe('useStructuredAgentSession transcript', () => { expect(result.current.journalItems.at(-1)).toMatchObject({ turnScope: SCOPE }) }) - it('names any other agent by its own label, never as Claude', () => { + // The notice names no agent: nothing in the journal says who cut the turn, so it never names + // Claude, or any other agent, for it. + it('words a cut turn the same for any other agent, never naming Claude', () => { const { result } = renderHook(() => useStructuredAgentSession({ sessionId: 'session-1', @@ -108,7 +111,8 @@ describe('useStructuredAgentSession transcript', () => { ) expect(result.current.messages.at(-1)).toMatchObject({ - blocks: [{ text: expect.stringMatching(/^Gemini stopped while/) }] + blocks: [{ text: 'This response was interrupted. You can continue in this conversation.' }] }) + expect(JSON.stringify(result.current.messages)).not.toContain('Claude') }) }) 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 fd220e76c9fa..682bced1bbdc 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 @@ -196,11 +196,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: structuredAgentLabel(agent) - }), - [agent, transportState.journalItems] + () => withNativeChatCutTurnNotices(transportState.journalItems), + [transportState.journalItems] ) const messages = useStructuredAgentSessionMessages( transcriptItems, 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 90b3274c4094..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 @@ -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,151 @@ describe('selectWorktreeAgentActivitySummary', () => { } } + // 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 } + }), + structuredHost: 'held', + ...overrides + }) + const staleCard = (...rows: AgentStatusEntry[]) => { + vi.spyOn(Date, 'now').mockReturnValue(1_000 + AGENT_STATUS_STALE_AFTER_MS + 60_000) + const summary = selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { [worktreeId]: [liveTab] }, + agentStatusEpoch: epoch++, + agentStatusByPaneKey: Object.fromEntries(rows.map((row) => [row.paneKey, row])), + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }, + worktreeId + ) + return { + ...summary, + status: resolveWorktreeStatus({ + tabs: [], + browserTabs: [], + ptyIdsByTabId: {}, + ...summary + }) + } + } + + // 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, + 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, hasRetainedFailed: false } + ) + }) + + // 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 ended = (outcome: 'failure' | 'cancellation' | 'interruption'): AgentStatusEntry => ({ + ...makeAgentStatusEntry({ + paneKey: failedKey, + state: 'done', + mainAgent: { state: 'done', outcome, stateStartedAt: endedAt } + }), + updatedAt: endedAt, + stateStartedAt: endedAt, + structuredHost: 'held' + }) + const fresh = (state: 'working' | 'done'): AgentStatusEntry => ({ + ...makeAgentStatusEntry({ paneKey: workingKey, state }), + updatedAt: at, + stateStartedAt: at + }) + const card = ( + rows: AgentStatusEntry[], + retainedAgentsByPaneKey: AgentActivityInput['retainedAgentsByPaneKey'] = {} + ) => { + vi.spyOn(Date, 'now').mockReturnValue(at) + const summary = selectWorktreeAgentActivitySummary( + { + tabsByWorktree: { [worktreeId]: [liveTab, retainedTab] }, + 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'), 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([ended('interruption')], departedDone)).toBe('interrupted') + expect(card([ended('cancellation')], departedDone)).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( + {}, + { + 'tab-2:0': { + ...retainedFailure['tab-2:0'], + entry: makeAgentStatusEntry({ paneKey: 'tab-2:0', state: 'done', mainAgent: cut }) + } + } + ) + expect(departed.summary).toMatchObject({ + hasRetainedInterrupted: 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..82b1b3c12266 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 { isExplicitAgentStatusFresh, isSettledNativeChatVerdict } from '@/lib/pane-agent-evidence' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { mergeAgentStatusOrchestration, @@ -19,14 +19,17 @@ 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. */ + /** 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 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 agentStatusPaneIdsByTabId: Record> /** Stale rows suppress generated permission labels while preserving native title fallback. */ stalePaneIdsByTabId: Record> @@ -44,6 +47,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 +138,9 @@ function getWorktreeAgentActivitySummaries( addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) continue } - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + const fresh = isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) + 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. @@ -144,7 +151,13 @@ function getWorktreeAgentActivitySummaries( if (entry.state === 'done') { addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId) } - applyAgentPaneActivityFlags(summary, entry) + // 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) + } } for (const unsupported of Object.values(state.migrationUnsupportedByPtyId ?? {})) { @@ -157,12 +170,7 @@ 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 - } + applyRetainedAgentMark(summary, retained.entry) const paneIdentity = parseAgentStatusPaneIdentity(retained.entry?.paneKey) if (paneIdentity) { addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) @@ -197,6 +205,28 @@ function getWorktreeAgentActivitySummaries( return summaries } +/** 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] +): 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 @@ -211,6 +241,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/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..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 @@ -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,45 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('interrupted') }) + // 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' | 'unconfirmed' | 'success', + overrides: Partial = {} + ) => + entry(FIRST_LEAF_ID, 'done', { + updatedAt: 0, + structuredHost: 'held', + mainAgent: { state: 'done', outcome, stateStartedAt: 0 }, + ...overrides + }) + vi.setSystemTime(AGENT_STATUS_STALE_AFTER_MS + 60_000) + const status = (row: AgentStatusEntry) => + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [row.paneKey]: row }, + ptyIdsByTabId: LIVE_PTY + }) + 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') + // 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(), + structuredHost: 'held' + }) + ) + ).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/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 57c9143f1eb0..6c8c9510416c 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2894,7 +2894,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 9cc7c3d90488..8e28f36fb4fa 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18141,6 +18141,7 @@ "plan": "Plan", "historyRepaired": "Part of this chat's history couldn't be loaded.", "historyItemTooLarge": "This part of the chat was too large to show.", + "responseInterrupted": "This response was interrupted. You can continue in this conversation.", "stoppedBeforeStart": "Stopped manually", "dismiss": "Dismiss", "copyError": "Copy error", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ca78deda08b2..3043a1f95292 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18283,6 +18283,7 @@ "plan": "Plan", "historyRepaired": "No se pudo cargar parte del historial de este chat.", "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla.", + "responseInterrupted": "Esta respuesta se interrumpió. Puedes continuar en esta conversación.", "stoppedBeforeStart": "Detenido manualmente", "dismiss": "Descartar", "copyError": "Copiar error", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a18e5d599a4b..211ee9143f73 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18268,6 +18268,7 @@ "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.", + "responseInterrupted": "Cette réponse a été interrompue. Vous pouvez continuer dans cette conversation.", "stoppedBeforeStart": "Arrêté manuellement", "dismiss": "Ignorer", "copyError": "Copier l'erreur", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 72efe22af4f2..63153eb3b13f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18202,6 +18202,7 @@ "plan": "プラン", "historyRepaired": "このチャットの履歴の一部を読み込めませんでした。", "historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。", + "responseInterrupted": "この応答は中断されました。この会話の続きから再開できます。", "stoppedBeforeStart": "手動で停止しました", "dismiss": "閉じる", "copyError": "エラーをコピー", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1a66b828be6a..e6d994763c4a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18202,6 +18202,7 @@ "plan": "계획", "historyRepaired": "이 채팅 기록의 일부를 불러올 수 없습니다.", "historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다.", + "responseInterrupted": "이 응답이 중단되었습니다. 이 대화에서 계속할 수 있습니다.", "stoppedBeforeStart": "수동으로 중지됨", "dismiss": "닫기", "copyError": "오류 복사", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cccf45c2ed35..36f02a4b0804 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18167,6 +18167,7 @@ "plan": "计划", "historyRepaired": "此聊天的部分历史记录无法加载。", "historyItemTooLarge": "聊天的这一部分过大,无法显示。", + "responseInterrupted": "此回复已中断。你可以在此对话中继续。", "stoppedBeforeStart": "已手动停止", "dismiss": "关闭", "copyError": "复制错误", 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.test.ts b/src/renderer/src/lib/pane-agent-evidence.test.ts index 0bd618e29406..bf68f5e1adab 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, + structuredHost: 'held', + 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', { structuredHost: 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 9c9ed4d4b805..ab0841433bf6 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 { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict' import type { TerminalAgent } from '../../../shared/terminal-agent' import { AGENT_STATUS_STALE_AFTER_MS, @@ -35,6 +36,37 @@ export function isExplicitAgentStatusFresh( ) } +/** + * 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] & + Parameters[0], + now: number, + staleAfterMs: number +): boolean { + return isExplicitAgentStatusFresh(entry, now, staleAfterMs) || isSettledNativeChatVerdict(entry) +} + /** * 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..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,14 +158,6 @@ 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 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/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index ac50b69742bc..7efc5fbe536f 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, a native chat's) must not pin the card over + // live work. if (args.hasRetainedFailed) { return 'failed' } @@ -235,7 +239,18 @@ export function resolveWorktreeStatus(args: { if (args.hasInterrupted) { return 'interrupted' } - if (args.hasLiveDone || args.hasRetainedDone) { + if (args.hasLiveDone) { + return 'done' + } + // 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 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/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index cd066a63a99c..e680d7e0a286 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 + 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 cd4c6da3d47f..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,6 +208,7 @@ export function buildAgentStatusLiveEntry( ? { evidenceObservedAt: timing.evidenceObservedAt } : {}), ...(metadata?.structuredHostOwned === true ? { structuredHostOwned: true as const } : {}), + ...(metadata?.structuredHost ? { structuredHost: metadata.structuredHost } : {}), stateStartedAt, stateObservedAt, ...(turnStartedAt !== undefined ? { turnStartedAt } : {}), 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/agent-session-host-status-rows.ts b/src/shared/agent-session-host-status-rows.ts index 880eb105ed0b..84acf2beb42b 100644 --- a/src/shared/agent-session-host-status-rows.ts +++ b/src/shared/agent-session-host-status-rows.ts @@ -7,7 +7,13 @@ 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, 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.', + /** A host's row about Orca stopping under a turn (a quit, an update, a crash), which names its + * cause in `orcaStop`; worded so only where the reader can't name the machine. */ + 'orca-stop': 'This response was interrupted. You can continue in this conversation.' } as const export type AgentSessionHostStatusPresentation = keyof typeof AGENT_SESSION_HOST_STATUS_COPY @@ -18,6 +24,24 @@ 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' } +} + +/** 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 { + return presentation === 'response-interrupted' || presentation === 'orca-stop' +} + export function isAgentSessionHostStatusPresentation( presentation: string | undefined ): presentation is AgentSessionHostStatusPresentation { diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 1fff0ea17e3e..8aa77db477e6 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,6 +91,10 @@ export type AgentStatusEntry = { evidenceObservedAt?: number /** True only while a host-held structured session is represented by its live status feed. */ structuredHostOwned?: true + /** 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 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..c5b78f3937e4 --- /dev/null +++ b/src/shared/native-chat-cut-turn-explanation.test.ts @@ -0,0 +1,109 @@ +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: 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: AgentJournalTurnScope = 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()] + ], + [ + // 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({ + 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.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 09fc37ba5773..f8c03ec72af3 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -9,6 +9,8 @@ import type { AgentJournalTurnOutcome, 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' @@ -21,7 +23,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 +111,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 +125,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 +134,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 +162,143 @@ describe('withNativeChatCutTurnNotices', () => { reply('a1', inTurn('t1')), exitRow(id, scope) ] - expect(withNativeChatCutTurnNotices(items), id).toBe(items) + expect(notices(items), id).toEqual([]) } }) + // 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')) + 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 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 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({ + kind: 'status', + text: NOTICE, + presentation: 'response-interrupted', + 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') + + 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 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') + } + }) + + // 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']) { 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 +453,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 +472,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 +480,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 +509,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 +533,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..f145ceb4e37e 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -3,113 +3,26 @@ // 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 { readAgentSessionFailureFact } from './agent-session-failure' import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from './agent-session-failure-words' -import { agentJournalItemKey, parseAgentJournalItemKey } from './agent-session-journal-item-key' -import { isRootAgentJournalItem } from './agent-session-journal-producer' + 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, 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:' -type CutTurnNoticeContext = Pick - -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( @@ -139,26 +52,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 +70,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 +78,74 @@ 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 host's row about an agent process gone from under a turn (a reopen's, or a quit's), in the + * 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 a row whose presentation says the turn was interrupted (`response-interrupted`, or + * `orca-stop`, which names its cause in fields a newer client words) but which is stored red for + * clients that fold every other row, 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') { + return item + } + const { presentation, tone } = item.body + const storedRed = isAgentSessionInterruptionPresentation(presentation) && tone !== 'notice' + const legacy = + presentation === undefined && + (tone === 'error' || readAgentSessionFailureFact(item.body.failure)?.kind === 'providerExited') + if (!storedRed && !legacy) { + return item + } + const cached = ownerDeathRowCache.get(item) + if (cached) { + return cached + } + const { failure: _failure, ...stored } = item.body + const reworded = { + ...item, + body: { + ...stored, + ...agentSessionResponseInterruptedBody(), + ...(presentation === undefined ? {} : { presentation }) + } + } + 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 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[], - 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) ?? []) + ]) } diff --git a/src/shared/native-chat-orca-stop-cut.test.ts b/src/shared/native-chat-orca-stop-cut.test.ts index 728b4a0d50aa..c51dd8a52ba2 100644 --- a/src/shared/native-chat-orca-stop-cut.test.ts +++ b/src/shared/native-chat-orca-stop-cut.test.ts @@ -42,8 +42,8 @@ function cutTurn(outcome?: AgentJournalTurnOutcome): AgentJournalRenderItem { } } -/** The host's row, as a host this build or a newer one writes it: today's words and fact, and the - * cause beside them. */ +/** The host's row as a host that still wrote the agent's words stored it: those words and fact, and + * the cause beside them. */ function stopRow(orcaStop: { cause: string } | undefined, scoped = true): AgentJournalRenderItem { return { itemId: agentJournalItemKey({ @@ -79,11 +79,10 @@ describe('the cause on a stopped row', () => { }) }) -describe('a client that predates the cause', () => { +describe('the transcript reader', () => { it("reads the host row as the cut turn's one explanation and adds none of its own", () => { const items = [userMessage(1), cutTurn(), stopRow(update)] - const read = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) - expect(read).toBe(items) + const read = withNativeChatCutTurnNotices(items) expect(read.filter((item) => item.body.kind === 'status')).toHaveLength(1) }) }) diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index c9fc746273f3..1e0a147d86a0 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'] @@ -121,14 +121,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 83da06a82702..b34054783b59 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -46,9 +46,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 nobody + * asked for (Orca crashing or quitting, an eviction) reads interrupted, and a failure, the + * agent's own exit included, reads failed. Only the cut gets the chat's notice row saying why. */ verdict?: AgentTurnOutcome }): { key: 'workingFor' | 'workedFor' | 'interruptedAfter' | 'failedAfter' @@ -65,11 +65,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/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index be8bd69ddc47..fc2d38496de9 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -14,6 +14,8 @@ import type { AgentJournalTurnLifecycleState, AgentJournalTurnOutcome } from './agent-session-journal-types' +import type { AgentTurnOutcome } from './agent-turn-outcome' +import { structuredAgentTurnVerdictReader } from './native-chat-cut-turn-explanation' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' @@ -35,6 +37,9 @@ export type StructuredAgentSessionLatestRequest = { turnState: AgentJournalTurnLifecycleState | null /** The provider's verdict. Null while the turn runs, and for a turn whose end carried none. */ outcome: AgentJournalTurnOutcome | null + /** The verdict a status row reports: `outcome`, else what the host observed of the turn's end, + * read as every surface reads it (`structuredAgentTurnVerdictReader`). Never announced. */ + verdict: AgentTurnOutcome | null /** When it settled: the turn's end, or the refusal. Undefined while it runs. */ settledAt: number | undefined } @@ -78,6 +83,7 @@ export function latestStructuredAgentSessionRequest( id: turn.turnId, turnState: turn.state, outcome: readAgentJournalTurnOutcome(turn), + verdict: structuredAgentTurnVerdictReader(items)(item), settledAt: turn.state === 'running' ? undefined : turnEndedAt(item, turn) } } @@ -88,6 +94,7 @@ export function latestStructuredAgentSessionRequest( id: item.itemId, turnState: null, outcome: 'failure', + verdict: 'failure', settledAt: submission.resolvedAt ?? undefined } } diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index dd5737d1c54e..431cdde9fdab 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -11,7 +11,7 @@ import { type AgentJournalRenderItem, type AgentJournalSubmission } from './agent-session-journal-types' -import { agentTurnVerdict, type AgentTurnOutcome } from './agent-turn-outcome' +import type { AgentTurnOutcome } from './agent-turn-outcome' import { agentJournalToolCallLifecycle } from './agent-journal-tool-call-lifecycle' import { agentJournalLinkageFields } from './agent-session-journal-producer' import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' @@ -239,8 +239,8 @@ export type StructuredAgentSessionStatusProjection = { toolInput?: string lastAssistantMessage?: string /** The latest request's verdict: its turn's, or `failure` for a send the agent or its start - * refused. A turn the provider gave none reads as its host-observed end. Present only while - * `status` is idle. */ + * refused. A turn the provider gave none reads as its host-observed end, and a cut its agent's + * own exit explains as `failure`. Present only while `status` is idle. */ turnOutcome?: AgentTurnOutcome statusStartedAt?: number } @@ -299,9 +299,7 @@ export function projectStructuredAgentSessionStatusState( const latestRequest = latestStructuredAgentSessionRequest(items, submissions) // A verdict is a fact about a finished request: only an idle session has one to report. const request = status === 'idle' ? latestRequest : null - const turnOutcome = request - ? agentTurnVerdict({ state: request.turnState, outcome: request.outcome }) - : null + const turnOutcome = request?.verdict ?? null const statusStartedAt = structuredAgentSessionStatusStartedAt( status, items, diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index 0ca1560d9811..8d14aff3d4ea 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -9,8 +9,9 @@ import type { AgentJournalSubmission, AgentJournalTurnLifecycleState } from './agent-session-journal-types' -import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' -import { agentTurnVerdict, type AgentTurnOutcome } from './agent-turn-outcome' +import { readAgentJournalTurn } from './agent-session-turn-record' +import type { AgentTurnOutcome } from './agent-turn-outcome' +import { structuredAgentTurnVerdictReader } from './native-chat-cut-turn-explanation' import { structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatSettledTurn, NativeChatSettledTurns } from './native-chat-turn-status' import type { AgentSessionLatestTurn } from './agent-session-wire' @@ -32,13 +33,16 @@ export type StructuredAgentTurnTiming = { /** Host clock when the lifecycle row was appended; with `startedAt` it gives * the host-side lag a client must subtract to anchor a live counter. */ observedAt: number - /** The turn's verdict, the provider's or the host-observed end's; absent when unknown. */ + /** The turn's verdict as every surface reads it (`structuredAgentTurnVerdictReader`); absent when + * unknown. */ verdict?: AgentTurnOutcome } function readTiming( item: Pick, - precedingTurnEndedAt: number | undefined + precedingTurnEndedAt: number | undefined, + /** The turn's verdict as every surface reads it (`structuredAgentTurnVerdictReader`). */ + verdict: AgentTurnOutcome | null ): StructuredAgentTurnTiming | null { const turn = readAgentJournalTurn(item.body) if (!turn) { @@ -60,7 +64,6 @@ function readTiming( durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : undefined - const verdict = agentTurnVerdict({ state, outcome: readAgentJournalTurnOutcome(turn) }) // 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 = @@ -95,6 +98,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) @@ -102,7 +106,7 @@ function readStructuredAgentJournalTurns( if (anchor === undefined || !turn) { continue } - const timing = readTiming(item, precedingTurnEndedAt) + const timing = readTiming(item, precedingTurnEndedAt, verdictOf(item)) precedingTurnEndedAt = timing?.completedAt ?? item.observedAt byTurnId.set(turn.turnId, timing) if (timing || turn.state === 'unverifiable') { @@ -238,7 +242,9 @@ export function selectStructuredAgentTurnBars( runningTiming: unloaded ? readTiming( { body: { kind: 'turn', ...unloaded.turn }, observedAt: unloaded.observedAt }, - undefined + undefined, + // Running: it has no verdict yet. + null ) : turnId === null ? null 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' })