From c5ac54f5ea34b84143f7634c75020b3bf4233699 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:57:44 -0700 Subject: [PATCH] fix(native-chat): an interrupted reply's note stops blaming the agent When a reopen finds the agent process gone, or a cut turn has no note, the chat said ' stopped while this response was in progress' in error red. Every row about a reply cut with nobody asking now stores one muted body, 'This response was interrupted. You can continue in this conversation.', on the existing Orca-stop card: a known cause keeps its wording, the host's no-cause row gets Continue inside the card, and a derived note never offers a Continue the host would refuse. An older host's red reopen row reads the same on a newer client. The agent's own crash row is unchanged. Replaces part of #25670. --- ...native-chat-merged-snapshot-parity.test.ts | 5 +- ...red-agent-session.cut-turn-notice.test.tsx | 4 +- .../use-mobile-structured-agent-session.ts | 8 +- .../acp/acp-structured-host-lifecycle.test.ts | 4 +- src/main/acp/acp-structured-host-stop.test.ts | 2 +- ...ured-agent-session-attach-orchestration.ts | 4 +- ...ctured-agent-session-close-verdict.test.ts | 11 +- ...uctured-agent-session-conversation-open.ts | 4 +- ...tured-agent-session-crash-turn-end.test.ts | 13 +- ...gent-session-dead-generation-settlement.ts | 2 - .../structured-agent-session-host-lifetime.ts | 5 - ...gent-session-orca-stop-row-readers.test.ts | 104 -------------- .../structured-agent-session-orca-stop-row.ts | 43 +----- ...gent-session-provider-child-record.test.ts | 5 +- ...agent-session-replaced-server-turn.test.ts | 4 +- ...ssion-restart-interruption-test-harness.ts | 12 +- ...nt-session-restart-stream-duration.test.ts | 2 +- ...red-agent-session-shutdown-cut-row.test.ts | 54 +++---- ...structured-agent-session-stale-stop-row.ts | 11 +- ...ured-agent-session-stale-turn-open.test.ts | 11 +- ...d-agent-session-stale-turn-verdict.test.ts | 5 +- ...d-agent-session-stop-event-restart.test.ts | 5 +- ...red-agent-session-surface-lifetime.test.ts | 10 +- ...t-session-wedged-profile-migration.test.ts | 9 +- .../native-chat/NativeChatNoticeRow.test.tsx | 36 ----- .../native-chat/NativeChatNoticeRow.tsx | 37 +++-- ...ucturedSession.orca-stop-continue.test.tsx | 133 +++++++++++------- .../native-chat-orca-stop-words.ts | 38 +++-- .../native-chat-transcript-slots.test.ts | 4 +- .../native-chat-transcript-slots.ts | 2 +- ...red-agent-session.cut-turn-notice.test.tsx | 26 +--- .../use-structured-agent-session.ts | 7 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + src/shared/agent-session-orca-stop.ts | 48 ++++++- src/shared/agent-session-stop-row-identity.ts | 2 + .../native-chat-cut-turn-notice.test.ts | 20 +-- src/shared/native-chat-cut-turn-notice.ts | 51 ++----- src/shared/native-chat-orca-stop-cut.test.ts | 6 +- src/shared/native-chat-orca-stop-cut.ts | 28 ++-- src/shared/native-chat-types.ts | 4 +- .../structured-agent-session-status-block.ts | 22 ++- 46 files changed, 324 insertions(+), 483 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts 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 89b22ac34617..2eab4982456d 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..18b74984a5d5 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -120,8 +120,8 @@ describe('useMobileStructuredAgentSession transcript', () => { role: 'system', blocks: [ { - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - tone: 'error' + text: 'This response was interrupted. You can continue in this conversation.', + tone: 'notice' } ] }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 3dd7b707e937..7d8b81a7ae94 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -132,13 +132,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: agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined - }), - [agent, state.items] - ) + const transcriptItems = useMemo(() => withNativeChatCutTurnNotices(state.items), [state.items]) const sends = useMobileStructuredSends({ sessionKey, submissions: state.submissions, 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 2f7799d34127..3f7a3600ff2d 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 2fdc842b335f..47471dd83b6f 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 } from './structured-agent-session-host-types' import { @@ -241,8 +240,7 @@ async function runAttachUnderAbort( fence, acquisitionGeneration, deathEvidence: priorDeathEvidence, - replaced: context.deps.store.replacedRuntime(sessionId), - failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord) + replaced: context.deps.store.replacedRuntime(sessionId) }) } 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..42f0b4e0fe81 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 @@ -11,6 +11,7 @@ import { } from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { AGENT_SESSION_ORCA_STOP_TEXT } from '../../../shared/agent-session-orca-stop' import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' @@ -125,17 +126,15 @@ 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 status row a reader's transcript shows, the cut turn's derived notice included. + const notices = withNativeChatCutTurnNotices(items).flatMap((item) => + item.body.kind === 'status' ? [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.' -] +const ONE_NOTICE = [AGENT_SESSION_ORCA_STOP_TEXT] function lastSummary(statuses: AgentSessionStatusEvent[]) { const last = statuses.at(-1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 85738139603c..fb274fc9b90a 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 @@ -173,8 +172,7 @@ async function settleGoneGeneration( fence: record.lease.runtimeFence, acquisitionGeneration: null, deathEvidence: record.lease.deathEvidence ?? null, - replaced: deps.store?.replacedRuntime(record.sessionId), - failureTextContext: structuredAgentSessionFailureWordsContext(record) + replaced: deps.store?.replacedRuntime(record.sessionId) }) } 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 cd9ecc7e7418..c71827fc4fae 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 @@ -4,6 +4,7 @@ // that runs for half a minute leaves one journal row at its start. The lease renewal the host // wrote every ten seconds is what saw the child working after that row. +import { AGENT_SESSION_ORCA_STOP_TEXT } from '../../../shared/agent-session-orca-stop' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -236,15 +237,11 @@ 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] : []) + 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' - }) + expect.objectContaining({ text: AGENT_SESSION_ORCA_STOP_TEXT, tone: 'notice' }) ]) const [timing] = selectStructuredAgentTurnTimings(items).values() expect( @@ -392,7 +389,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 0a32aa14d6fd..c833b744a1eb 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 @@ -197,8 +197,6 @@ export async function settleStaleStructuredAgentSessionState(input: { deathEvidence: AgentSessionDeathEvidence | null /** The runtimes this one replaced for this chat: every owner they held lost its pipes with them. */ replaced?: AgentSessionReplacedRuntime - /** Who the exit row names. */ - failureTextContext?: AgentSessionFailureWordsContext }): Promise { const { journal, replaced } = input const items = journal.snapshot().items 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 deleted file mode 100644 index 74b5abe1bc31..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// 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. - -import { describe, expect, it } from 'vitest' -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' -import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' -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 TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 }) - -function cutChat(row: AgentJournalRenderItem['body']): AgentJournalRenderItem[] { - const scope = { kind: 'turn' as const, turnItemId: TURN } - return [ - { - itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: 'user-1' }), - revision: 1, - sequence: 1, - observedAt: 1, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } - }, - { - itemId: TURN, - revision: 2, - sequence: 2, - observedAt: 2, - body: { kind: 'turn', turnId: 'cut', state: 'interrupted', startedAt: 1, completedAt: 5 } - }, - { - itemId: agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 2 }), - revision: 1, - sequence: 3, - observedAt: 3, - turnScope: scope, - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Looking.' }] } - }, - { - itemId: agentJournalItemKey({ - provider: 'orca', - clientMessageId: 'stale-session:session-1:shutdown-3-gen-1' - }), - revision: 1, - sequence: 4, - observedAt: 6, - turnScope: scope, - body: row - } - ] -} - -describe('the row a host writes for a cut it knows the cause of', () => { - const row = orcaStopRowBody({ agentName: 'Codex' }, 'update') - - it("keeps today's words and tone, and names its presentation and cause beside them", () => { - expect(row).toMatchObject({ - kind: 'status', - text: LEGACY_TEXT, - tone: 'error', - failure: { kind: 'providerExited' }, - presentation: 'orca-stop', - orcaStop: { cause: 'update' } - }) - }) - - it('reads, on a client that predates the cause, as the one row it prints today, on screen', () => { - 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 - ]) - // 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, - role: item.body.kind === 'message' ? item.body.role : ('system' as const), - rendersProse: item.body.kind !== 'turn', - draws: true, - outlivesTurn: false, - reportsFailure: item.body.kind === 'status' && item.body.tone === 'error', - explainsTurn: false - })) - const { foldedRows } = nativeChatTurnFold({ - rows, - settledTurnKeys: new Set([TURN]), - expandedTurnKeys: new Set() - }) - expect(foldedRows.has(3)).toBe(false) - }) - - it('offers Continue on this build', () => { - expect(latestNativeChatOrcaStopCut(cutChat(row), [])).toEqual({ - turnItemId: TURN, - cause: 'update' - }) - }) - - it('names no presentation when the cause is unknown, so it reads as any owner death', () => { - expect(orcaStopRowBody({ agentName: 'Codex' }, undefined)).not.toHaveProperty('presentation') - }) -}) 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..4f6e8c088bbb 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 @@ -1,23 +1,11 @@ -// The row that says why a quit or update cut a reply: the cut turn's own explanation, in the words -// every client already prints for a stopped agent, with the cause beside them for clients that -// name it (`AgentSessionOrcaStop`). +// The row that says why a quit or update cut a reply: the cut turn's own explanation, with the cause +// beside it 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' +import { agentSessionOrcaStopRowBody } 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 { - AgentJournalRenderItem, - AgentJournalStatusItem -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn, readAgentJournalTurnOutcome @@ -29,26 +17,6 @@ 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 root turn running as the quit stops the child: the one its stop may cut. */ export function runningRootTurnItemId( journal: Pick @@ -84,7 +52,6 @@ export async function recordStructuredAgentSessionShutdownCut(input: { generation: string turnItemId: string | null trigger: AgentSessionResumeTrigger - failureTextContext?: AgentSessionFailureWordsContext logger: StructuredAgentSessionLogger }): Promise { try { @@ -107,7 +74,7 @@ export async function recordStructuredAgentSessionShutdownCut(input: { } await input.journal.appendItem( { provider: 'orca', clientMessageId }, - orcaStopRowBody(input.failureTextContext, input.trigger), + agentSessionOrcaStopRowBody(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 c7a9997117e3..80ee12ab8b08 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 @@ -3,6 +3,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa // start it ends the child, never the conversation. Against the real host, store and journal, with a // live subscriber opened before each action. +import { AGENT_SESSION_ORCA_STOP_TEXT } from '../../../shared/agent-session-orca-stop' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -373,9 +374,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(AGENT_SESSION_ORCA_STOP_TEXT) // 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-replaced-server-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts index 822fb635b966..e13ae71e8d02 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts @@ -131,9 +131,7 @@ function repliesOf(items: readonly AgentJournalRenderItem[]): string[] { } function stopNotices(items: readonly AgentJournalRenderItem[]): AgentJournalRenderItem[] { - return withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).filter( - (item) => item.body.kind === 'status' - ) + return withNativeChatCutTurnNotices(items).filter((item) => item.body.kind === 'status') } async function expectCutTurnSettledOnce(): Promise { 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 db9107193361..b1a98001f9db 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 @@ -3,6 +3,7 @@ import { type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' +import { AGENT_SESSION_ORCA_STOP_TEXT } from '../../../shared/agent-session-orca-stop' // A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the // restart-resume ownership and failure tests. @@ -226,11 +227,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[]) { @@ -240,10 +237,7 @@ function notesOf(items: readonly AgentJournalRenderItem[]) { } /** The one row the quit's cut turn reads with when nothing else explains it. */ -export const QUIT_CUT_NOTICE = { - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - tone: 'error' -} +export const QUIT_CUT_NOTICE = { text: AGENT_SESSION_ORCA_STOP_TEXT, tone: 'notice' } /** 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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts index 5deb2e482db9..ffb8e006c382 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts @@ -121,7 +121,7 @@ async function expectSettledStream(): Promise { const [timing] = selectStructuredAgentTurnTimings(snapshot.items).values() expect(completedStructuredAgentTurnSeconds(timing)).toBe(5) expect( - withNativeChatCutTurnNotices(snapshot.items, { agentName: 'Codex' }).filter( + withNativeChatCutTurnNotices(snapshot.items).filter( (item) => item.body.kind === 'status' && item.body.text !== 'host notice' ) ).toHaveLength(1) 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..aa9e6006b8db 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 @@ -1,6 +1,5 @@ -// A quit or update that cuts a reply writes one row saying so, in the words every client already -// prints, with the cause beside them. A turn that finished, a person's Stop and an idle eviction -// write none. +// A quit or update that cuts a reply writes one muted row saying so, with the cause beside it. A +// turn that finished, a person's Stop and an idle eviction write none. import { rm } from 'node:fs/promises' import { join } from 'node:path' @@ -9,7 +8,10 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' -import { readAgentSessionOrcaStop } from '../../../shared/agent-session-orca-stop' +import { + AGENT_SESSION_ORCA_STOP_TEXT as STOP_TEXT, + readAgentSessionOrcaStop +} from '../../../shared/agent-session-orca-stop' import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' @@ -36,8 +38,6 @@ 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.' let host: StructuredAgentSessionHost /** How the provider ends its open turn as it is stopped. */ @@ -129,10 +129,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 readerNotes = withNativeChatCutTurnNotices(items).flatMap((item) => + item.body.kind === 'status' && item.body.presentation === 'orca-stop' ? [item.body.text] : [] ) - return { items, turnItemId, stopRows, readerErrors } + return { items, turnItemId, stopRows, readerNotes } } describe('the row a quit writes for the reply it cut', () => { @@ -141,20 +141,19 @@ 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, readerNotes } = await reread() expect(stopRows).toHaveLength(1) const [row] = stopRows - expect(row?.body).toMatchObject({ + expect(row?.body).toEqual({ kind: 'status', - text: LEGACY_TEXT, - tone: 'error', - failure: { kind: 'providerExited' }, + text: STOP_TEXT, + tone: 'notice', presentation: 'orca-stop', orcaStop: { cause: trigger } }) 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]) + // A client that reads no cause still prints exactly one row for the cut. + expect(readerNotes).toEqual([STOP_TEXT]) expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: trigger }) }) @@ -164,9 +163,9 @@ describe('the row a quit writes for the reply it cut', () => { await host.flushAllStreamedEvents({ trigger: 'update' }) - const { stopRows, readerErrors } = await reread() + const { stopRows, readerNotes } = await reread() expect(stopRows).toEqual([]) - expect(readerErrors).toEqual([]) + expect(readerNotes).toEqual([]) }) it("writes nothing for a turn a person's Stop already ended", async () => { @@ -184,10 +183,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, readerNotes } = 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(readerNotes).toEqual([STOP_TEXT]) expect(latestNativeChatOrcaStopCut(items, [])).toBeNull() }) }) @@ -198,13 +197,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, readerNotes } = await reread() expect(stopRows).toHaveLength(1) expect(stopRows[0]?.body).toMatchObject({ - text: LEGACY_TEXT, + text: STOP_TEXT, orcaStop: { cause: 'crash' } }) - expect(readerErrors).toEqual([LEGACY_TEXT]) + expect(readerNotes).toEqual([STOP_TEXT]) expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' }) }) @@ -225,10 +224,11 @@ describe('the row a restart writes for a reply its Orca died in', () => { await restartAfterDeath() - const { stopRows, readerErrors } = await reread() + const { items, turnItemId, stopRows, readerNotes } = await reread() expect(stopRows).toEqual([]) - // The cut keeps today's words. - expect(readerErrors).toEqual([LEGACY_TEXT]) + expect(readerNotes).toEqual([STOP_TEXT]) + // The host's own row for the cut, so Continue is offered on it. + expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-stop-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-stop-row.ts index ad39aa2975b0..26cf5f4cc508 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-stop-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-stop-row.ts @@ -3,7 +3,6 @@ // after a partly written settle adds no second row. import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' -import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { AgentJournalRenderItem, AgentJournalStatusItem @@ -11,7 +10,7 @@ import type { import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionReplacedRuntime } from '../../runtime/agent-session-replaced-runtime' -import { orcaStopRowBody } from './structured-agent-session-orca-stop-row' +import { agentSessionOrcaStopRowBody } from '../../../shared/agent-session-orca-stop' /** Which proof ends an item the settle found running or left `unverifiable`, if any does. */ export type StaleCut = { by: 'death' } | { by: 'replaced'; fence: number } | null @@ -25,7 +24,6 @@ export function staleStopRow( sessionId: string deathEvidence: AgentSessionDeathEvidence | null replaced?: AgentSessionReplacedRuntime - failureTextContext?: AgentSessionFailureWordsContext }, items: readonly AgentJournalRenderItem[], cutBy: (item: AgentJournalRenderItem) => StaleCut @@ -37,7 +35,7 @@ export function staleStopRow( const cuts = items.map(cutBy) const evidence = input.deathEvidence // 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. + // the reply was cut, and how Orca ended when the provider died with it. if (evidence && cuts.some((cut) => cut?.by === 'death')) { return { kind: 'item', @@ -45,7 +43,7 @@ export function staleStopRow( provider: 'orca', clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` }, - body: orcaStopRowBody(input.failureTextContext, evidence.runtimeEnd) + body: agentSessionOrcaStopRowBody(evidence.runtimeEnd) } } const fences = cuts.flatMap((cut) => (cut?.by === 'replaced' ? [cut.fence] : [])) @@ -60,8 +58,7 @@ export function staleStopRow( provider: 'orca', clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:replaced-${fence}` }, - body: orcaStopRowBody( - input.failureTextContext, + body: agentSessionOrcaStopRowBody( fence === input.replaced.fence ? input.replaced.runtimeEnd : undefined ) } 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..deb3988b651c 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 @@ -2,6 +2,7 @@ // the conversation, not only by the next send: a reader reopening a chat the idle sweep closed, or // reading it before the restart restore reaches it. +import { AGENT_SESSION_ORCA_STOP_TEXT } from '../../../shared/agent-session-orca-stop' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -78,9 +79,7 @@ describe('a turn its gone agent left running', () => { expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) 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.' - ) + expect(JSON.stringify(items)).toContain(AGENT_SESSION_ORCA_STOP_TEXT) expect(rig.adapter.acquire).toHaveBeenCalledOnce() }) @@ -95,10 +94,8 @@ 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. - expect(JSON.stringify(reader.events)).toContain( - 'Codex stopped while this response was in progress. You can continue in this conversation.' - ) + // The death evidence is Orca's log text: the row says only that the reply was interrupted. + expect(JSON.stringify(reader.events)).toContain(AGENT_SESSION_ORCA_STOP_TEXT) 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 9efb8202f880..fd7b043b42ee 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 @@ -1,4 +1,5 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_ORCA_STOP_TEXT } from '../../../shared/agent-session-orca-stop' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -505,9 +506,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([AGENT_SESSION_ORCA_STOP_TEXT]) } 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..dc491e30d269 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 @@ -189,9 +189,8 @@ describe('a restart between a Stop and its turn end', () => { 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') - ]) + // Orca's cut reads muted: nothing says the agent failed. + expect(errorRows).toEqual([]) }) it("reads Couldn't confirm when the relaunch cannot prove the child gone, Stop or not", async () => { 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..e50d06a79b48 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 @@ -40,6 +40,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentSessionOrcaStopRowBody } from '../../../shared/agent-session-orca-stop' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' @@ -827,19 +828,14 @@ 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 reply was interrupted. 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( expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', completedAt: NOW }) ) const statuses = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) - expect(statuses).toContainEqual({ - kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: { kind: 'providerExited' }, - tone: 'error' - }) + expect(statuses).toContainEqual(agentSessionOrcaStopRowBody(undefined)) 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 6727b0144c03..6c232efc5af7 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 @@ -11,6 +11,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa // leave the lease in a state an attach can claim, and these tests prove that by adjudicating it // rather than by reading fields off it. +import { AGENT_SESSION_ORCA_STOP_TEXT } from '../../../shared/agent-session-orca-stop' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -352,13 +353,7 @@ describe('already-wedged profiles become usable on load', () => { const statusRows = restoredJournal() .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.' - ] - ) + expect(statusRows).toEqual(remote ? [] : [AGENT_SESSION_ORCA_STOP_TEXT]) // What the sidebar reads: every status this restart published says the chat is not working. expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([]) expect(published.map((summary) => summary.status)).not.toContain('working') diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index 63a12f7ce1f5..d29f12b3bec2 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -157,42 +157,6 @@ describe('the row an Orca stop leaves', () => { expect(screen.getByText(sentence)).toBeInTheDocument() }) - // A client that re-words unnamed host rows keeps this row's presentation and cause, neutral. - it('stays neutral, naming the cause, as a reader that re-presented it neutral hands it on', () => { - // 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.', - tone: 'notice', - presentation: 'orca-stop', - orcaStop: { cause: 'crash' } - } - renderStatus(represented, 'studio-mac', true) - expect( - screen.getByText( - 'Orca on studio-mac stopped unexpectedly while this response was in progress.' - ).parentElement?.parentElement - ).toHaveClass('text-chat-foreground-faint') - cleanup() - renderStatus(represented, null, true) - expect( - screen.getByText('This response was interrupted. You can continue in this conversation.') - .parentElement?.parentElement - ).toHaveClass('text-chat-foreground-faint') - }) - - it('keeps the host words for a cause this build does not know', () => { - renderStatus(orcaStopRow('power-loss'), 'studio-mac') - expect(screen.getByText(LEGACY_TEXT)).toBeInTheDocument() - }) - - it('keeps the host words when the chat has no machine to name, muted all the same', () => { - renderStatus(orcaStopRow('update'), null) - expect(screen.getByText(LEGACY_TEXT).parentElement?.parentElement).toHaveClass( - 'text-chat-foreground-faint' - ) - }) - it('calls a paired server with no name the remote server, so its words match the button', () => { renderStatus(orcaStopRow('update'), null, true, true) expect( diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 1c90ffa45530..15eacf29362f 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -14,7 +14,10 @@ import { import type { NativeChatTextBlock } from '../../../../shared/native-chat-types' import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' import { NativeChatInterruptedContinueButton } from './NativeChatInterruptedContinue' -import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' +import { + nativeChatInterruptedRowText, + 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' @@ -113,9 +116,10 @@ export function NativeChatNoticeRow({ ) } - // The host's row about an Orca stop names the cause and the machine, muted: Orca stopped, not the - // agent. Outside a chat that knows its machine it keeps the host's own words. + // A reply cut with nobody asking reads muted: nothing failed. A known Orca stop names its cause and + // machine; otherwise the row says only that the reply was interrupted. const { orcaStop } = block + const cause = orcaStop?.cause const { hostLabel, remoteHost, continueAvailable } = orcaStopView // A paired server with no name to show is still a remote server; its id is not a name. const machine = @@ -123,7 +127,8 @@ export function NativeChatNoticeRow({ (remoteHost ? translate('components.native-chat.notices.orcaStopUnnamedServer', 'the remote server') : null) - const named = orcaStop !== undefined && machine !== null + const named = cause !== undefined && machine !== null + const interrupted = block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION const continueOffered = orcaStop?.turnItemId !== undefined && orcaStop.turnItemId === orcaStopView.offeredTurnItemId const failure = readWholeAgentSessionFailureFact(block.failure) @@ -135,17 +140,19 @@ export function NativeChatNoticeRow({ ) : undefined const text = named - ? nativeChatOrcaStopRowText(orcaStop.cause, machine, { continueAvailable, remoteHost }) - : failure && authSurface - ? agentSessionFailureSentence( - failure, - authSurface, - { agentName }, - sayAgentSessionFailureTranslated - ) - : block.text - const tone = - named || block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ? 'notice' : block.tone + ? nativeChatOrcaStopRowText(cause, machine, { continueAvailable, remoteHost }) + : interrupted + ? // Only a host's own row can be continued; a derived notice keeps saying how to go on. + nativeChatInterruptedRowText(continueAvailable && orcaStop !== undefined) + : failure && authSurface + ? agentSessionFailureSentence( + failure, + authSurface, + { agentName }, + sayAgentSessionFailureTranslated + ) + : block.text + const tone = named || interrupted ? 'notice' : block.tone const Icon = tone === 'warning' ? AlertTriangle diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx index 86fceb7065a4..8d1d11ee0833 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.orca-stop-continue.test.tsx @@ -1,9 +1,9 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' -import { useImperativeHandle } from 'react' -import { afterEach, expect, it, vi } from 'vitest' +import { afterAll, afterEach, expect, it, vi } from 'vitest' import type * as HostCapabilityModule from '@/runtime/structured-agent-session-host-capability' +import type * as NativeChatMessageListModule from './NativeChatMessageList' const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() @@ -36,28 +36,25 @@ vi.mock('@/runtime/structured-agent-session-host-capability', async (importOrigi : original.useStructuredAgentSessionHostCapabilityState(...args) } }) -// The transcript's real rows, projected from the journal; only the list's windowing is left out. -vi.mock('./NativeChatMessageList', async () => { - const { MessageRow } = await import('./NativeChatMessageRow') - const { projectStructuredItemsToNativeChat } = - await import('../../../../shared/structured-agent-session-projection') +// The real list, collapsed, over the journal the controller hands it. +vi.mock('./NativeChatMessageList', async (importOriginal) => { + const { NativeChatMessageList } = await importOriginal() + const { projectStructuredAgentSessionMessages } = + await import('../../../../shared/structured-agent-session-message-projection') + const { selectStructuredAgentSettledTurns } = + await import('../../../../shared/structured-agent-session-turn-timing') return { - NativeChatMessageList: (props: { - ref?: React.Ref - journalItems?: Parameters[0] - }) => { - useImperativeHandle(props.ref, () => ({ revealLatest: () => {}, revealFindMatch: () => {} })) + NativeChatMessageList: (props: React.ComponentProps) => { + const items = props.journalItems ?? [] return ( -
- {projectStructuredItemsToNativeChat(props.journalItems ?? []).map((message) => ( - {}} - /> - ))} -
+ ) } } @@ -66,41 +63,59 @@ vi.mock('./NativeChatMessageList', async () => { import { TooltipProvider } from '@/components/ui/tooltip' import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalItemBody, + AgentJournalStatusItem +} from '../../../../shared/agent-session-journal-types' +import { agentSessionOrcaStopRowBody } from '../../../../shared/agent-session-orca-stop' +import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' -const TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 }) +let sequence = 0 +const item = (itemId: string, body: AgentJournalItemBody, turnItemId?: string) => ({ + itemId, + revision: 1, + sequence: ++sequence, + observedAt: sequence, + turnScope: turnItemId ? { kind: 'turn' as const, turnItemId } : { kind: 'thread' as const }, + body +}) +const work = { type: 'tool-call', name: 'shell', input: {}, state: 'completed' } as const -const cutChat: AgentJournalRenderItem[] = [ - { - itemId: TURN, - revision: 2, - sequence: 1, - observedAt: 1, - body: { kind: 'turn', turnId: 'cut', state: 'interrupted', startedAt: 1, completedAt: 5 } - }, - { - itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: 'stale-session:s:death-1-5' }), - revision: 1, - sequence: 2, - observedAt: 6, - turnScope: { kind: 'turn', turnItemId: TURN }, - body: { - kind: 'status', - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - tone: 'error', - presentation: 'orca-stop', - orcaStop: { cause: 'crash' } - } - } -] +/** A turn cut short after some work, and the row a host wrote about it. */ +function cutTurn(id: string, row: [clientMessageId: string, body: AgentJournalStatusItem]) { + const turn = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: id, ordinal: 1 }) + return [ + item(`user-${id}`, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }), + item(turn, { kind: 'turn', turnId: id, state: 'interrupted', startedAt: 1, completedAt: 5 }), + item(`work-${id}`, { kind: 'message', role: 'assistant', blocks: [work] }, turn), + item(agentJournalItemKey({ provider: 'orca', clientMessageId: row[0] }), row[1], turn) + ] +} +const AGENT_CRASH = + 'Codex stopped while this response was in progress. You can continue in this conversation.' +const agentExit: AgentJournalStatusItem = { kind: 'status', text: AGENT_CRASH, tone: 'error' } + +const cutChat = withNativeChatCutTurnNotices([ + // An older host's reopen row, red and blaming the agent. + ...cutTurn('old', ['stale-session:s:death-0-1', agentExit]), + ...cutTurn('quit', ['stale-session:s:shutdown-1-g1', agentSessionOrcaStopRowBody('crash')]), + // The agent's own exit while Orca ran. + ...cutTurn('exit', ['provider-exit:s:2:g2', agentExit]), + // A reopen found the agent gone and nothing says why. + ...cutTurn('gone', ['stale-session:s:death-3-9', agentSessionOrcaStopRowBody(undefined)]) +]) + +const restoreViewport = installNativeChatMessageListTestViewport() +afterAll(restoreViewport) afterEach(() => { cleanup() resetStructuredSessionMocks() }) -it('offers one Continue, in the card that says Orca stopped, and the card does not say it again', () => { - mocks.journalItems = cutChat +it('keeps each collapsed cut explained once, and offers one Continue, in the neutral card', () => { + mocks.journalItems = [...cutChat] render( ) + const collapsed = { name: 'Toggle turn details', expanded: false } + expect(screen.getAllByRole('button', collapsed)).toHaveLength(4) + // A known cause keeps its wording. + screen.getByText(/^Orca on the remote server stopped unexpectedly while this response/) + // The agent's own exit stays red. + expect(screen.getByText(AGENT_CRASH).parentElement?.parentElement).toHaveClass('text-destructive') // Throws on a second Continue anywhere in the pane, such as the old row above the composer. const button = screen.getByRole('button', { name: 'Continue' }) - const sentence = screen.getByText( - 'Orca on the remote server stopped unexpectedly while this response was in progress.' - ) + // The older host's row reads neutral too, with no Continue, which that host would refuse. + expect( + screen.getAllByText(/^This response was interrupted/).map((row) => row.textContent) + ).toEqual([ + 'This response was interrupted. You can continue in this conversation.', + 'This response was interrupted.' + ]) + const sentence = screen.getByText('This response was interrupted.') expect(sentence.parentElement).toContainElement(button) - expect(screen.getByTestId('transcript')).toContainElement(button) - expect(screen.queryByText(/You can continue in this conversation/)).toBeNull() + expect(sentence.parentElement?.parentElement).toHaveClass('text-chat-foreground-faint') }) diff --git a/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts b/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts index 10df0deab3d6..7f4860c9c721 100644 --- a/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts +++ b/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts @@ -1,4 +1,4 @@ -// The words for a reply Orca's own stop cut off, by cause and machine. One function, so the chat's +// The words for a reply cut off with nobody asking, by cause and machine. One module, so the chat's // row wording can change in one place. import { translate } from '@/i18n/i18n' @@ -42,15 +42,10 @@ function stoppedSentence( return cause === 'quit' && remoteHost ? remoteQuit(machine) : STOPPED[cause](machine) } -/** The row's sentence. Where the host can continue a cut, Continue is the way on, so the row does - * not say it again; decided by the host, not the button, so the words never change on screen. */ -export function nativeChatOrcaStopRowText( - cause: AgentSessionOrcaStopCause, - machine: string, - options: { continueAvailable: boolean; remoteHost: boolean } -): string { - const stopped = stoppedSentence(cause, machine, options.remoteHost) - return options.continueAvailable +/** Where the host can continue a cut, Continue is the way on, so the row does not say it again; + * decided by the host, not the button, so the words never change on screen. */ +function withContinueHint(stopped: string, continueAvailable: boolean): string { + return continueAvailable ? stopped : joinSentences([ stopped, @@ -60,3 +55,26 @@ export function nativeChatOrcaStopRowText( ) ]) } + +/** The row's sentence, naming why Orca stopped and on which machine. */ +export function nativeChatOrcaStopRowText( + cause: AgentSessionOrcaStopCause, + machine: string, + options: { continueAvailable: boolean; remoteHost: boolean } +): string { + return withContinueHint( + stoppedSentence(cause, machine, options.remoteHost), + options.continueAvailable + ) +} + +/** The row's sentence when no cause is known. */ +export function nativeChatInterruptedRowText(continueAvailable: boolean): string { + return withContinueHint( + translate( + 'components.native-chat.notices.orcaStopInterrupted', + 'This response was interrupted.' + ), + continueAvailable + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index ff82e898fea5..1aa43999a7b7 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -283,7 +283,7 @@ describe('a turn no message opened', () => { ]) }) - // Stored red for clients that predate it, but Orca stopped, not the agent: no failure, never folded. + // An older host stores it red, but Orca stopped, not the agent: no failure, never folded. it("keeps the row about Orca's stop on screen beside the reply it cut, which stays the answer", () => { const orcaStop: NativeChatMessage = { ...failure('orca-stop'), @@ -306,7 +306,7 @@ describe('a turn no message opened', () => { ['a1', false], ['orca-stop', false] ]) - // The same, once a reader re-presented it neutral. + // The same, as this build's host stores it, neutral. const neutral: NativeChatMessage = { ...orcaStop, blocks: orcaStop.blocks.map((block) => 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 2b4a748a79de..f64a992bc4d6 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 @@ -153,7 +153,7 @@ 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. + // An older host stores its row about Orca's stop red; it reports no failure. reportsFailure: message.blocks.some( (block) => block.type === 'text' && 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 29c2253dddfa..3a8f7d99780c 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 @@ -72,6 +72,8 @@ vi.mock('./use-structured-agent-session-sends', () => ({ })) import { useStructuredAgentSession } from './use-structured-agent-session' +import { AGENT_SESSION_ORCA_STOP_TEXT } from '../../../../shared/agent-session-orca-stop' +import { latestNativeChatOrcaStopCut } from '../../../../shared/native-chat-orca-stop-cut' describe('useStructuredAgentSession transcript', () => { // A root left mounted can commit after happy-dom is torn down ("window is not defined"). @@ -89,29 +91,11 @@ describe('useStructuredAgentSession transcript', () => { expect(result.current.messages.at(-1)).toMatchObject({ role: 'system', - blocks: [ - { - text: 'Codex stopped while this response was in progress. You can continue in this conversation.', - tone: 'error' - } - ] + blocks: [{ text: AGENT_SESSION_ORCA_STOP_TEXT, tone: 'notice', presentation: 'orca-stop' }] }) // The list places rows by the same items, so the notice joins the cut turn. expect(result.current.journalItems.at(-1)).toMatchObject({ turnScope: SCOPE }) - }) - - it('names any other agent by its own label, never as Claude', () => { - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - agent: 'gemini', - target: { kind: 'local' }, - isVisible: true - }) - ) - - expect(result.current.messages.at(-1)).toMatchObject({ - blocks: [{ text: expect.stringMatching(/^Gemini stopped while/) }] - }) + // No host wrote it, so no host could continue from it. + expect(latestNativeChatOrcaStopCut(result.current.journalItems, [])).toBeNull() }) }) 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 25d8915056c0..e36f2a87f2c6 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 @@ -209,11 +209,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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d34597f293d6..82a1461a97bf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18367,6 +18367,7 @@ "orcaStopCrash": "Orca on {{machine}} stopped unexpectedly while this response was in progress.", "orcaStopRemote": "Orca on {{machine}} stopped while this response was in progress.", "orcaStopCanContinue": "You can continue in this conversation.", + "orcaStopInterrupted": "This response was interrupted.", "orcaStopUnnamedServer": "the remote server" }, "composer": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 117b97b9ce94..d53b9a02db83 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18549,6 +18549,7 @@ "orcaStopCrash": "Orca en {{machine}} se detuvo inesperadamente mientras esta respuesta estaba en curso.", "orcaStopRemote": "Orca en {{machine}} se detuvo mientras esta respuesta estaba en curso.", "orcaStopCanContinue": "Puedes continuar en esta conversación.", + "orcaStopInterrupted": "Esta respuesta se interrumpió.", "orcaStopUnnamedServer": "el servidor remoto" }, "providerFrame": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5702ac8578b8..717a6aa6f2e0 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18528,6 +18528,7 @@ "orcaStopCrash": "Orca sur {{machine}} s'est arrêté de manière inattendue pendant que cette réponse était en cours.", "orcaStopRemote": "Orca sur {{machine}} s'est arrêté pendant que cette réponse était en cours.", "orcaStopCanContinue": "Vous pouvez continuer dans cette conversation.", + "orcaStopInterrupted": "Cette réponse a été interrompue.", "orcaStopUnnamedServer": "le serveur distant" }, "providerFrame": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index fabd07dc8c2d..640f02c45efb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18459,6 +18459,7 @@ "orcaStopCrash": "この応答の途中で、{{machine}} の Orca が予期せず停止しました。", "orcaStopRemote": "この応答の途中で、{{machine}} の Orca が停止しました。", "orcaStopCanContinue": "この会話で続行できます。", + "orcaStopInterrupted": "この応答は中断されました。", "orcaStopUnnamedServer": "リモートサーバー" }, "providerFrame": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 96d76bc6ed00..1033a768170d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18459,6 +18459,7 @@ "orcaStopCrash": "이 응답이 진행되는 동안 {{machine}}의 Orca가 예기치 않게 중지되었습니다.", "orcaStopRemote": "이 응답이 진행되는 동안 {{machine}}의 Orca가 중지되었습니다.", "orcaStopCanContinue": "이 대화에서 계속할 수 있습니다.", + "orcaStopInterrupted": "이 응답이 중단되었습니다.", "orcaStopUnnamedServer": "원격 서버" }, "providerFrame": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 93f4d075a81b..d551ee838f35 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18424,6 +18424,7 @@ "orcaStopCrash": "此回复进行期间,{{machine}} 上的 Orca 意外停止。", "orcaStopRemote": "此回复进行期间,{{machine}} 上的 Orca 已停止。", "orcaStopCanContinue": "你可以在此对话中继续。", + "orcaStopInterrupted": "此回复已中断。", "orcaStopUnnamedServer": "远程服务器" }, "providerFrame": { diff --git a/src/shared/agent-session-orca-stop.ts b/src/shared/agent-session-orca-stop.ts index f3d452826486..9e9c22aead1f 100644 --- a/src/shared/agent-session-orca-stop.ts +++ b/src/shared/agent-session-orca-stop.ts @@ -1,6 +1,13 @@ -// Why the Orca running a chat stopped in the middle of a reply: a quit, an update, or a crash. The -// host states it on its row about the cut turn (`orcaStop`); a client names it, and offers Continue. -// Open on the wire: a cause this build does not know reads as none, and the row keeps its words. +// The host's row about a reply cut with nobody asking, and why the Orca running the chat stopped +// when it was Orca: a quit, an update, or a crash (`orcaStop`). A client names the cause, and offers +// Continue. Open on the wire: a cause this build does not know reads as none. + +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' +import type { + AgentJournalPlainStatusItem, + AgentJournalRenderItem +} from './agent-session-journal-types' +import { CUT_TURN_NOTICE_ROW_PREFIX } from './agent-session-stop-row-identity' export const AGENT_SESSION_ORCA_STOP_CAUSES = ['update', 'quit', 'crash'] as const /** The row's presentation, so a client that re-words host rows it can't name passes it through. */ @@ -18,3 +25,38 @@ export function readAgentSessionOrcaStop(value: unknown): AgentSessionOrcaStop | typeof value === 'object' && value !== null && 'cause' in value ? value.cause : undefined return isAgentSessionOrcaStopCause(cause) ? { cause } : undefined } + +/** The row's own words, which a client that names no cause prints as they are. */ +export const AGENT_SESSION_ORCA_STOP_TEXT = + 'This response was interrupted. You can continue in this conversation.' + +/** The row about a reply cut with nobody asking: muted, since nothing failed, and naming why Orca + * stopped when it was Orca. */ +export function agentSessionOrcaStopRowBody(orcaEnd: unknown): AgentJournalPlainStatusItem { + return { + kind: 'status', + text: AGENT_SESSION_ORCA_STOP_TEXT, + tone: 'notice', + presentation: AGENT_SESSION_ORCA_STOP_PRESENTATION, + ...(isAgentSessionOrcaStopCause(orcaEnd) ? { orcaStop: { cause: orcaEnd } } : {}) + } +} + +/** The stop a host's row states, with its cause when this build knows one; null for any other row. + * A client's derived notice is none: no host can continue from a row it never wrote. */ +export function readAgentSessionOrcaStopRow( + item: Pick +): Partial | null { + if (item.body.kind !== 'status') { + return null + } + const stop = readAgentSessionOrcaStop(item.body.orcaStop) + if (stop || item.body.presentation !== AGENT_SESSION_ORCA_STOP_PRESENTATION) { + return stop ?? null + } + const identity = parseAgentJournalItemKey(item.itemId) + return identity?.provider === 'orca' && + identity.clientMessageId.startsWith(CUT_TURN_NOTICE_ROW_PREFIX) + ? null + : {} +} diff --git a/src/shared/agent-session-stop-row-identity.ts b/src/shared/agent-session-stop-row-identity.ts index 8d9fac96f11f..5204016a5514 100644 --- a/src/shared/agent-session-stop-row-identity.ts +++ b/src/shared/agent-session-stop-row-identity.ts @@ -10,3 +10,5 @@ export const PROVIDER_EXIT_ROW_PREFIX = 'provider-exit:' export const STALE_SESSION_ROW_PREFIX = 'stale-session:' /** The note the restart continuation leaves about its outcome. */ export const RESTART_CONTINUATION_ROW_PREFIX = 'restart-continuation:' +/** The notice a client derives for a cut turn no row explains; never stored, so no host knows it. */ +export const CUT_TURN_NOTICE_ROW_PREFIX = 'cut-turn-notice:' diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts index 0b1057c4374d..fa2e9131227b 100644 --- a/src/shared/native-chat-cut-turn-notice.test.ts +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -10,6 +10,7 @@ import type { AgentJournalTurnScope } from './agent-session-journal-types' import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' +import { AGENT_SESSION_ORCA_STOP_TEXT } from './agent-session-orca-stop' import { hostStatesTurnScopes, nativeChatTurnMembership } from './native-chat-turn-membership' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' import { @@ -21,8 +22,7 @@ import { import { structuredAgentSessionStartFailureRowIdentity } from './structured-agent-session-start-failure-row-key' const THREAD: AgentJournalTurnScope = { kind: 'thread' } -const NOTICE = - 'Codex stopped while this response was in progress. You can continue in this conversation.' +const NOTICE = AGENT_SESSION_ORCA_STOP_TEXT let sequence = 0 function item( @@ -107,7 +107,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 +121,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, after its last row', () => { const items = [ user('u1'), turn('t1', 'u1', CUT), @@ -130,11 +130,11 @@ 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' } }) + expect(notice.body).toMatchObject({ tone: 'notice', presentation: 'orca-stop' }) // 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) @@ -338,13 +338,13 @@ 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, [], []) const { turnKeys } = nativeChatTurnMembership(messages, { items: derived, submissions: [] }) 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, tone: 'notice' }) ]) expect(turnKeys[notice]).toBe('u1') }) @@ -373,7 +373,7 @@ describe('withNativeChatCutTurnNotices', () => { resolvedAt: 60 } ] - const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const derived = withNativeChatCutTurnNotices(items) const messages = projectStructuredAgentSessionMessages(derived, [], submissions) const { turnKeys } = nativeChatTurnMembership(messages, { items: derived, submissions }) @@ -390,7 +390,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..3398a0b128e8 100644 --- a/src/shared/native-chat-cut-turn-notice.ts +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -4,17 +4,15 @@ // 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. -import { agentSessionFailureFact, readAgentSessionFailureFact } from './agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from './agent-session-failure-words' +import { readAgentSessionFailureFact } from './agent-session-failure' +import { agentSessionOrcaStopRowBody } from './agent-session-orca-stop' import { agentJournalItemKey, 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 } from './agent-turn-outcome' import { + CUT_TURN_NOTICE_ROW_PREFIX, PROVIDER_EXIT_ROW_PREFIX, RESTART_CONTINUATION_ROW_PREFIX, STALE_SESSION_ROW_PREFIX @@ -22,10 +20,6 @@ import { import { isStructuredAgentSessionStartFailureRow } from './structured-agent-session-start-failure-row-key' import { hostStatesTurnScopes } from './native-chat-turn-membership' -const CUT_TURN_NOTICE_ROW = 'cut-turn-notice:' - -type CutTurnNoticeContext = Pick - function rootTurnVerdict( item: AgentJournalRenderItem ): ReturnType | 'none' { @@ -139,42 +133,26 @@ 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 = { itemId: agentJournalItemKey({ provider: 'orca', - clientMessageId: `${CUT_TURN_NOTICE_ROW}${turnItem.itemId}` + clientMessageId: `${CUT_TURN_NOTICE_ROW_PREFIX}${turnItem.itemId}` }), revision: 0, - body: { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { - ...context, - surface: 'row' - }), - tone: 'error' - }, + // No cause: nothing recorded why the turn was cut. + body: agentSessionOrcaStopRowBody(undefined), // Just after the turn's last row and before anything the journal wrote next. sequence: after.sequence, sequenceIndex: (after.sequenceIndex ?? 0) + 0.5, @@ -182,18 +160,17 @@ 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 } /** * 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 notice that it was interrupted, right after the turn's last row. Returns + * `items` itself when no turn needs one. */ export function withNativeChatCutTurnNotices( - items: readonly AgentJournalRenderItem[], - context: CutTurnNoticeContext = {} + items: readonly AgentJournalRenderItem[] ): readonly AgentJournalRenderItem[] { const explained = explainedCutTurns(items) const statesScopes = hostStatesTurnScopes(items) @@ -201,7 +178,7 @@ export function withNativeChatCutTurnNotices( 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]) } }) diff --git a/src/shared/native-chat-orca-stop-cut.test.ts b/src/shared/native-chat-orca-stop-cut.test.ts index 728b4a0d50aa..0d3d033a6abe 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 an older host writes it: red words blaming the agent, and the cause beside + * them. */ function stopRow(orcaStop: { cause: string } | undefined, scoped = true): AgentJournalRenderItem { return { itemId: agentJournalItemKey({ @@ -82,7 +82,7 @@ describe('the cause on a stopped row', () => { describe('a client that predates the cause', () => { 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' }) + const read = withNativeChatCutTurnNotices(items) expect(read).toBe(items) expect(read.filter((item) => item.body.kind === 'status')).toHaveLength(1) }) diff --git a/src/shared/native-chat-orca-stop-cut.ts b/src/shared/native-chat-orca-stop-cut.ts index cab5825ea6e1..426bd5d64cb5 100644 --- a/src/shared/native-chat-orca-stop-cut.ts +++ b/src/shared/native-chat-orca-stop-cut.ts @@ -1,9 +1,9 @@ -// A reply cut off because the Orca running the chat stopped (an update, a quit, a crash), as the -// host's row about it says, and whether the chat is still sitting on that cut. Derived from the -// journal each time, never stored: the client shows Continue from it, and the host re-checks it +// A reply cut off with nobody asking, as the host's row about it says (an update, a quit, a crash, +// or an owner a reopen found gone), and whether the chat is still sitting on that cut. Derived from +// the journal each time, never stored: the client shows Continue from it, and the host re-checks it // under the session lock before Continue sends anything. -import { readAgentSessionOrcaStop, type AgentSessionOrcaStopCause } from './agent-session-orca-stop' +import { readAgentSessionOrcaStopRow, type AgentSessionOrcaStop } from './agent-session-orca-stop' import { isRootAgentJournalItem } from './agent-session-journal-producer' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { STALE_SESSION_ROW_PREFIX } from './agent-session-stop-row-identity' @@ -23,17 +23,7 @@ export function orcaShutdownRowClientMessageId( return `${STALE_SESSION_ROW_PREFIX}${sessionId}:shutdown-${fence}-${generation}` } -/** Why Orca stopped, from a status row the host wrote about it; null for any other row. */ -export function orcaStopCauseOfRow(item: AgentJournalRenderItem): AgentSessionOrcaStopCause | null { - return item.body.kind === 'status' - ? (readAgentSessionOrcaStop(item.body.orcaStop)?.cause ?? null) - : null -} - -export type NativeChatOrcaStopCut = { - turnItemId: string - cause: AgentSessionOrcaStopCause -} +export type NativeChatOrcaStopCut = Partial & { turnItemId: string } /** * The latest root turn, when an Orca stop cut it and nothing was sent since: no request after it, @@ -76,12 +66,12 @@ export function latestNativeChatOrcaStopCut( return null } for (const item of items) { - const cause = + const stop = item.turnScope?.kind === 'turn' && item.turnScope.turnItemId === turnItem.itemId - ? orcaStopCauseOfRow(item) + ? readAgentSessionOrcaStopRow(item) : null - if (cause) { - return { turnItemId: turnItem.itemId, cause } + if (stop) { + return { turnItemId: turnItem.itemId, ...stop } } } return null diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index ea35e3ac5318..408989f4fa1c 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -66,8 +66,8 @@ export type NativeChatTextBlock = { } /** On a status line that reports a failure: what failed, typed. */ failure?: AgentSessionFailureFact - /** On the line about a reply Orca's own stop cut off: why, and the turn it cut. */ - orcaStop?: AgentSessionOrcaStop & { turnItemId?: string } + /** On a host's line about a reply cut with nobody asking: why, when Orca's stop, and the turn. */ + orcaStop?: Partial & { turnItemId?: string } } /** A tool invocation by the agent. `input` is the (already-serialized) tool diff --git a/src/shared/structured-agent-session-status-block.ts b/src/shared/structured-agent-session-status-block.ts index 2febb6af6872..058217e8b93e 100644 --- a/src/shared/structured-agent-session-status-block.ts +++ b/src/shared/structured-agent-session-status-block.ts @@ -1,20 +1,36 @@ import { readWholeAgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalStatusItem, AgentJournalTurnScope } from './agent-session-journal-types' -import { readAgentSessionOrcaStop } from './agent-session-orca-stop' +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' +import { agentSessionOrcaStopRowBody, readAgentSessionOrcaStopRow } from './agent-session-orca-stop' +import { STALE_SESSION_ROW_PREFIX } from './agent-session-stop-row-identity' import type { NativeChatTextBlock } from './native-chat-types' import { AGENT_SESSION_COMPACTION_SKIPPED_PRESENTATION, isAgentSessionLegacyCompactionResult } from './agent-session-compaction' +/** An older host's red row about an owner a reopen found gone, which named no cause and blamed the + * agent: stored with no presentation, under the prefix every such row has. */ +function isOlderHostStaleSessionRow(body: AgentJournalStatusItem, itemId: string | undefined) { + const identity = itemId === undefined ? null : parseAgentJournalItemKey(itemId) + return ( + body.presentation === undefined && + identity?.provider === 'orca' && + identity.clientMessageId.startsWith(STALE_SESSION_ROW_PREFIX) + ) +} + /** A status row as the line a chat paints: named fields only, so a host-only key never leaks. */ export function structuredAgentSessionStatusBlock( - body: AgentJournalStatusItem, + stored: AgentJournalStatusItem, turnScope?: AgentJournalTurnScope, itemId?: string ): NativeChatTextBlock { + // Painted as this build's row, but offering no Continue, which that host would refuse. + const olderHostRow = isOlderHostStaleSessionRow(stored, itemId) + const body = olderHostRow ? agentSessionOrcaStopRowBody(undefined) : stored const failure = readWholeAgentSessionFailureFact(body.failure) - const orcaStop = readAgentSessionOrcaStop(body.orcaStop) + const orcaStop = olderHostRow ? null : readAgentSessionOrcaStopRow({ itemId: itemId ?? '', body }) const presentation = body.presentation ?? (body.tone === 'warning' && itemId !== undefined && isAgentSessionLegacyCompactionResult(itemId)