diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index d2069a134b24..a0393996cec3 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -78,14 +78,15 @@ describe('MobileNativeChatTurnStatus', () => { expect(onToggleExpanded).toHaveBeenCalledOnce() }) - it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => { + // 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', () => { const crashed = render({ startedAt: Date.now(), workedSeconds: 12, verdict: 'interruption', onToggleExpanded: vi.fn() }) - expect(labels(crashed.root)).toEqual(['Failed after 12s']) + expect(labels(crashed.root)).toEqual(['Worked for 12s']) act(() => crashed.unmount()) const stopped = render({ startedAt: Date.now(), 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 new file mode 100644 index 000000000000..37a29458c07d --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -0,0 +1,132 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +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' +import type { RpcResponse } from '../transport/types' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) + +const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' } +const ITEMS: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + revision: 0, + sequence: 1, + observedAt: 1, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + turnScope: { kind: 'thread' }, + body: { + kind: 'turn', + turnId: 'turn-1', + userItemId: 'user-1', + state: 'interrupted', + startedAt: 2, + completedAt: 9 + } + }, + { + itemId: 'reply-1', + revision: 0, + sequence: 3, + observedAt: 3, + turnScope: SCOPE, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] } + } +] + +function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: 'session-1', + fence: 3, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 3 }, + nextCursor: { epoch: 'epoch-1', sequence: 4 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 3 }, + hasOlder: false, + hasNewer: false + } + } +} + +describe('useMobileStructuredAgentSession transcript', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => { + let listener: ((value: unknown) => void) | null = null + const client: Pick = { + sendRequest: vi.fn(async (): Promise => ({ + id: 'response-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + })), + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return () => {} + }) + } + const rendered: { hook: ReturnType | null } = { + hook: null + } + function Harness(): null { + rendered.hook = useMobileStructuredAgentSession({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided. + client: client as RpcClient, + sessionId: 'session-1', + enabled: true, + connected: true, + hostSupport: null, + agent: 'codex', + onSendError: vi.fn() + }) + return null + } + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + + act(() => listener?.(snapshot(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' + } + ] + }) + ) + // 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 }) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index ae688ab90723..b0ecfe9832c7 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef } from 'react' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' 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 { activeStructuredAgentSessionTurnId, @@ -157,16 +159,28 @@ export function useMobileStructuredAgentSession(args: { onSendError }) + // 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 messages = useMemo( // Off: the phone hands a rejected message back to its composer, so a row would show it twice. () => - projectStructuredAgentSessionMessages(state.items, [], state.submissions, { + projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, { rejectedInPlace: false }), - [state.items, state.submissions] + [transcriptItems, state.submissions] ) const turnId = activeStructuredAgentSessionTurnId(state.items) - const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId) + const turnTiming = useMobileStructuredAgentTurnTiming( + { ...state, items: transcriptItems }, + turnId + ) const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) 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 33ef8080c030..b1922b913ed1 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 @@ -12,6 +12,7 @@ import { import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' 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' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -121,9 +122,19 @@ async function settledTurn() { const { items } = await host.journalSnapshot(SESSION) const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] - return { turn, settled } + 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] : [] + ) + 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.' +] + function lastSummary(statuses: AgentSessionStatusEvent[]) { const last = statuses.at(-1) return last?.type === 'status' ? last.session : null @@ -136,11 +147,11 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'user-close') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) - const { turn, settled } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'interruptedAfter' }) + expect(label).toBe('interruptedAfter') + // The person's own close explains itself. + expect(notices).toEqual([]) }) it('leaves a close the user did not aim at this chat as news', async () => { @@ -150,12 +161,12 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'evict') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) - const { turn, settled } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + // News for the sidebar; the turn reads like a finished one beside its one notice. + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("records the user's close on a turn no adapter settled, through the host's fallback", async () => { @@ -247,10 +258,9 @@ describe('a turn cut short by closing its provider', () => { expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ session: { status: 'idle', turnOutcome: 'interruption' } }) - const { settled } = await settledTurn() - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + const { label, notices } = await settledTurn() + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { @@ -297,9 +307,10 @@ describe('a turn cut short by closing its provider', () => { await host.collaboratorsForTests().lifetime.idleSweep.tick() expect(closeCalls).toBe(1) - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn?.outcome).toBe(verdict.outcome) + expect(notices).toEqual(cause === 'evict' ? ONE_NOTICE : []) } ) @@ -309,9 +320,10 @@ describe('a turn cut short by closing its provider', () => { await host.flushAllStreamedEvents({ trigger: 'quit' }) - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + expect(notices).toEqual(ONE_NOTICE) }) it('leaves a crash the provider saw before the user closed the chat as news', async () => { @@ -319,9 +331,10 @@ describe('a turn cut short by closing its provider', () => { await runningTurn() // The provider reports its own exit, which it saw first, as it closes: no verdict. await host.close(SESSION, 'user-close') - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + expect(notices).toEqual(ONE_NOTICE) }) it.each(['user-close', 'evict'] as const)( 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 7e2376150891..d31a79e5f54b 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 @@ -38,6 +38,7 @@ import { } from './structured-agent-session-adapter' import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -216,6 +217,34 @@ 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. + 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' }) }) + + await host.restoreReadableSessions() + + const { items } = await host.journalSnapshot(SESSION) + // As a reader's transcript shows it: the stored row is the explanation, so none is derived. + const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap( + (item) => (item.body.kind === 'status' ? [item.body] : []) + ) + 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' + }) + ]) + const [timing] = selectStructuredAgentTurnTimings(items).values() + expect( + describeNativeChatTurnStatus({ + elapsedSeconds: 0, + workedSeconds: completedStructuredAgentTurnSeconds(timing), + verdict: timing?.verdict + }) + ).toEqual({ key: 'workedFor', duration: '27s' }) + }) + it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => { // The orphan is alive at relaunch, but its output went nowhere: none of that is work shown. let alive = true @@ -298,7 +327,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () unsubscribe() }) - it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => { + it('reports the revision to the status feed as an interruption, which the chat folds as worked', async () => { const published: AgentSessionStatusSummary[] = [] openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }), @@ -314,7 +343,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () await host.reconcileRestartLeases() await drainSession() - // The sidebar's red Failed, then the folded "Failed after 27s". + // The sidebar's red Failed until seen; the turn folds as "Worked for 27s" beside its notice row. await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption')) const [timing] = selectStructuredAgentTurnTimings( (await host.journalSnapshot(SESSION)).items @@ -325,7 +354,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () workedSeconds: completedStructuredAgentTurnSeconds(timing), verdict: timing?.verdict }) - ).toEqual({ key: 'failedAfter', duration: '27s' }) + ).toEqual({ key: 'workedFor', duration: '27s' }) }) it('revises nothing twice, whoever re-runs the settle', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 39ff1c7ba514..f0372c8e58de 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 @@ -4,6 +4,7 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -229,7 +230,7 @@ export async function settleStaleStructuredAgentSessionState(input: { ) // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` + const settlementId = `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:${input.fence}:${generation}` const mutations: JournalLifecycleMutationInput[] = [] for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) @@ -261,7 +262,7 @@ export async function settleStaleStructuredAgentSessionState(input: { // Named by the death it explains, so a retry after a partly written settle adds no second row. identity: { provider: 'orca', - clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` + 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. 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 e8dfe9f7f5e6..0857e3565d69 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 @@ -183,8 +183,8 @@ describe('a turn recovery settled after its host went away', () => { } ) - // The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and - // must never show the done tick of a finished turn. + // 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. it.each([ [ 'a restart', @@ -208,7 +208,7 @@ describe('a turn recovery settled after its host went away', () => { ) ] ] as const)( - 'reads Failed after N, marked failed, for a turn cut off by %s', + 'reads Worked for N, marked failed, for a turn cut off by %s', async (_label, cut) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -221,7 +221,7 @@ describe('a turn recovery settled after its host went away', () => { ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() ] expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( - 'Failed after 1s' + 'Worked for 1s' ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 27c09bc2bbe1..c748748ccd41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -22,6 +22,7 @@ import { type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -120,7 +121,10 @@ function restartNoteWriter( return } await session.journal.appendItem( - { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, + { + provider: 'orca', + clientMessageId: `${RESTART_CONTINUATION_ROW_PREFIX}${sessionId}:${host.now()}` + }, { kind: 'status', text, ...(tone ? { tone } : {}) }, // About the conversation, not any turn in it. { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } 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 b1cbc83a48e6..4602630960a9 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 @@ -1,12 +1,15 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { + AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { interruptedRestart, + QUIT_CUT_NOTICE, + readerNotes, statusNotes, throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' @@ -43,6 +46,8 @@ 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([]) expect(await statusNotes(host)).toEqual([]) + // No note took over, so the cut turn keeps its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) }) // The continuation is accepted and its agent then fails to start: the message is rejected with the @@ -51,6 +56,8 @@ it('says so in the chat when the agent cannot start for the continuation', async const { host, acquire } = await interruptedRestart() await host.restartResume.list() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + // Before the action, the quit's cut reads with its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) await host.restartResume.continueAfterRestart([SESSION], 'modal') @@ -61,6 +68,24 @@ it('says so in the chat when the agent cannot start for the continuation', async 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)) +}) + +// A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in +// for the cut: its note is written after its own message, so the cut keeps its notice throughout. +it("keeps the quit's notice through a continuation that carries the chat on", async () => { + const { host } = await interruptedRestart() + await host.restartResume.list() + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + expect(await readerNotes(host)).toEqual([ + QUIT_CUT_NOTICE, + { text: AGENT_SESSION_RESTART_CONTINUATION_NOTE, tone: undefined } + ]) }) // A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, 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 fe97834a1891..59d478cbb40a 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 @@ -1,4 +1,8 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' // 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. @@ -177,11 +181,30 @@ export function throwAfterContinuationAccepted(): void { } export async function statusNotes(host: StructuredAgentSessionHost) { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + return notesOf((await host.journalSnapshot(SESSION)).items) +} + +/** 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' + }) + ) +} + +function notesOf(items: readonly AgentJournalRenderItem[]) { + return items.flatMap((item) => item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] ) } +/** 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' +} + /** 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-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 4ae9f59a015a..50056eedd031 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 @@ -158,7 +158,8 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) - it('reads Failed after N, marked failed, when nobody stopped it', async () => { + // 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 () => { await runningTurn(CODEX_TURN) await restartAndSettle() @@ -166,7 +167,7 @@ describe('a restart between a Stop and its turn end', () => { const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(label).toMatch(/^Failed after /) + expect(label).toMatch(/^Worked for /) expect(mark).toBe('failed') expect(errorRows).toEqual([ expect.stringContaining('stopped while this response was in progress') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 232b101e6035..5a0b2f6de30a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,4 +1,5 @@ import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' @@ -193,5 +194,5 @@ function logExitFailure( } function providerExitSettlementId(event: UnexpectedExitLifecycleEvent): string { - return `provider-exit:${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` + return `${PROVIDER_EXIT_ROW_PREFIX}${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` } 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 13f98dd4249e..acce19bcf523 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,7 +112,8 @@ function session(): NativeChatLiveSession { } describe('NativeChatMessageList folded turn headers', () => { - it('says a turn a crash cut off failed and a stopped turn was interrupted, where a finished one worked', () => { + // 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', () => { render( { .map((button) => [button.textContent, button.getAttribute('aria-expanded')]) expect(headers).toEqual([ ['Worked for 12s', 'false'], - ['Failed after 12s', 'false'], + ['Worked for 12s', 'false'], ['Interrupted after 12s', 'false'] ]) // The turn's detail stays inside the fold. 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 new file mode 100644 index 000000000000..fcb474f2acc6 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.cut-turn-notice.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' } +const items: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + revision: 0, + sequence: 1, + observedAt: 1, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + turnScope: { kind: 'thread' }, + body: { + kind: 'turn', + turnId: 'turn-1', + userItemId: 'user-1', + state: 'interrupted', + startedAt: 2, + completedAt: 9 + } + }, + { + itemId: 'reply-1', + revision: 0, + sequence: 3, + observedAt: 3, + turnScope: SCOPE, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] } + } +] + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(async () => null), + supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false) +})) +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: vi.fn(), + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + error: null, + send: vi.fn(), + retry: vi.fn() + }) +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' + +describe('useStructuredAgentSession transcript', () => { + it('shows a cut turn no row explains with its one notice, placed in that turn', () => { + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true + }) + ) + + 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' + } + ] + }) + // The list places rows by the same items, so the notice joins the cut turn. + expect(result.current.journalItems.at(-1)).toMatchObject({ turnScope: SCOPE }) + }) +}) 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 46571fcf54f3..0f126fe0559c 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 @@ -38,6 +38,8 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent- import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' +import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -164,8 +166,16 @@ export function useStructuredAgentSession(args: { }), [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds] ) + // What the transcript reads: the journal plus the one notice a cut turn with no row gets. + const transcriptItems = useMemo( + () => + withNativeChatCutTurnNotices(transportState.journalItems, { + agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + }), + [agent, transportState.journalItems] + ) const messages = useStructuredAgentSessionMessages( - transportState.journalItems, + transcriptItems, transcriptOutbox, transportState.submissions, queuedMessageIds @@ -200,7 +210,7 @@ export function useStructuredAgentSession(args: { { command } ) }), - journalItems: transportState.journalItems, + journalItems: transcriptItems, subagentRoster: transportState.subagentRoster, messages, status: transportEnabled ? state.status : 'ready', diff --git a/src/shared/agent-session-stop-row-identity.ts b/src/shared/agent-session-stop-row-identity.ts new file mode 100644 index 000000000000..d314003a3f99 --- /dev/null +++ b/src/shared/agent-session-stop-row-identity.ts @@ -0,0 +1,12 @@ +// The identities of the rows a host writes about a stop, shared with the readers that must not say +// the same stop twice (`native-chat-cut-turn-notice`). +// +// Contract: a new row that explains a stop must carry the providerExited fact or one of these +// prefixes, or a client derives a second notice beside it. + +/** The exit row the unexpected-exit settle writes, keyed by the child that exited. */ +export const PROVIDER_EXIT_ROW_PREFIX = 'provider-exit:' +/** The exit row a reopen or acquire writes for a generation found dead. */ +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:' diff --git a/src/shared/native-chat-cut-turn-notice.test.ts b/src/shared/native-chat-cut-turn-notice.test.ts new file mode 100644 index 000000000000..09fc37ba5773 --- /dev/null +++ b/src/shared/native-chat-cut-turn-notice.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { agentJournalItemKey, agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalSubmission, + AgentJournalRenderItem, + AgentJournalTurnOutcome, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' +import { hostStatesTurnScopes, nativeChatTurnMembership } from './native-chat-turn-membership' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { + AGENT_SESSION_RESTART_CONTINUATION_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE +} from './agent-session-restart-continuation' +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.' +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + /** Null for a host that predates scopes. */ + turnScope: AgentJournalTurnScope | null = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + ...(turnScope ? { turnScope } : {}) + } +} + +const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) +const user = (itemId: string, scope: AgentJournalTurnScope | null = THREAD) => + item(itemId, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }, scope) +const reply = (itemId: string, scope: AgentJournalTurnScope | null) => + item( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] }, + scope + ) + +type Ending = { + state: 'running' | 'completed' | 'interrupted' | 'unverifiable' + outcome?: AgentJournalTurnOutcome +} +const CUT: Ending = { state: 'interrupted' } + +function turn( + itemId: string, + userItemId: string, + ending: Ending, + scope: AgentJournalTurnScope | null = THREAD +) { + return item( + itemId, + { + kind: 'turn', + turnId: itemId, + userItemId, + startedAt: 1_000, + ...(ending.state === 'running' ? {} : { completedAt: 2_000 }), + ...ending + }, + scope + ) +} + +/** A row the host wrote, by the identity its writer gives it. */ +function hostRow( + clientMessageId: string, + body: AgentJournalItemBody, + scope: AgentJournalTurnScope | null +) { + return item(agentJournalItemKey({ provider: 'orca', clientMessageId }), body, scope) +} + +/** What the crash path's exit row says, from its fact. */ +const exitWords = (): AgentJournalItemBody => ({ + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + agentName: 'Codex', + surface: 'row' + }), + tone: 'error' +}) +const exitRow = (clientMessageId: string, scope: AgentJournalTurnScope | null) => + hostRow(clientMessageId, exitWords(), scope) +/** A note the restart continuation leaves about its outcome, about the conversation. */ +const restartNote = (text: string, tone?: 'error' | 'warning') => + hostRow( + `restart-continuation:s:${text.length}`, + { kind: 'status', text, ...(tone ? { tone } : {}) }, + THREAD + ) + +function notices(items: readonly AgentJournalRenderItem[]) { + return withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((entry, index) => + entry.itemId.includes('cut-turn-notice') + ? [ + { + index, + text: entry.body.kind === 'status' ? entry.body.text : null, + scope: entry.turnScope + } + ] + : [] + ) +} + +describe('withNativeChatCutTurnNotices', () => { + it('gives a cut turn no row explains one notice in the exit row words, after its last row', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', { state: 'completed', outcome: 'success' }) + ] + + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + const notice = derived[3]! + expect(notice.body).toMatchObject({ tone: 'error', failure: { kind: 'providerExited' } }) + // 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) + }) + + it('adds nothing where the crash path already wrote its exit row, scoped or about the conversation', () => { + for (const [id, scope] of [ + ['stale-session:s:death-3-2000', inTurn('t1')], + ['provider-exit:s:3:gen', inTurn('t1')], + ['stale-session:s:death-3-2000', THREAD] + ] as const) { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + exitRow(id, scope) + ] + expect(withNativeChatCutTurnNotices(items), id).toBe(items) + } + }) + + // A host from before failure facts wrote the same rows with only their words. + it("counts an older host's exit row by its writer, with no exit fact", () => { + for (const id of ['provider-exit:s:3:gen', 'stale-session:s:death-3-2000']) { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + hostRow(id, { kind: 'status', text: NOTICE, tone: 'error' }, inTurn('t1')) + ] + expect(withNativeChatCutTurnNotices(items), id).toBe(items) + } + }) + + it('counts an exit row by its exit fact, whatever wrote it', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + item('other-writer', exitWords(), inTurn('t1')) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + it("still explains a cut whose only error row is the agent's own, not the stop's", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + hostRow( + 'provider-frame:permission_denied', + { kind: 'status', text: 'Permission denied: Bash', tone: 'error' }, + inTurn('t1') + ) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + it('says nothing of a Stop, a replaced turn, a failure, an unproven end, a finished or a running turn', () => { + for (const ending of [ + { state: 'interrupted', outcome: 'cancellation' }, + { state: 'interrupted', outcome: 'superseded' }, + { state: 'interrupted', outcome: 'failure' }, + { state: 'completed', outcome: 'failure' }, + { state: 'unverifiable' }, + { state: 'completed', outcome: 'success' }, + { state: 'completed' }, + { state: 'running' } + ] satisfies Ending[]) { + const items = [user('u1'), turn('t1', 'u1', ending), reply('a1', inTurn('t1'))] + expect(withNativeChatCutTurnNotices(items), JSON.stringify(ending)).toBe(items) + } + }) + + it("says nothing of a subagent's cut turn: the conversation's own turn speaks for it", () => { + const items = [ + user('u1'), + turn('t1', 'u1', { state: 'completed', outcome: 'success' }), + { ...turn('sub', 'u1', CUT, inTurn('t1')), agentId: 'sub-agent' } + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + it('explains every cut turn in the history no row explains, each once', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', CUT), + reply('a2', inTurn('t2')), + exitRow('provider-exit:s:4:gen', inTurn('t2')), + user('u3'), + turn('t3', 'u3', CUT) + ] + expect(notices(items)).toEqual([ + { index: 3, text: NOTICE, scope: inTurn('t1') }, + // A turn with no rows of its own takes the notice after its record. + { index: 10, text: NOTICE, scope: inTurn('t3') } + ]) + }) + + // A quit, then a resume that did not carry the chat on: the note says the cut was not continued, + // which is what the notice would say, so it stands alone. + it.each([ + [AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, 'error'], + [AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, 'error'], + [AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, 'warning'] + ] as const)( + 'takes the restart note "%s" as the one explanation of the cut it follows', + (text, tone) => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('continuation'), + restartNote(text, tone) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + } + ) + + // The note of a continuation that went on lands after its own message and turn. The cut keeps its + // notice before and after it, so nothing flashes away under an automatic resume. + it('keeps the notice once a continuation carries the chat on', () => { + const cut = [user('u1'), turn('t1', 'u1', CUT), reply('a1', inTurn('t1'))] + const continuing = [...cut, user('u2'), turn('t2', 'u2', { state: 'running' })] + const continued = [...continuing, restartNote(AGENT_SESSION_RESTART_CONTINUATION_NOTE)] + + for (const items of [cut, continuing, continued]) { + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + } + // Its note carries no tone, so even with no turn recorded before it, it does not stand in. + const noteOnly = [...cut, user('u2'), restartNote(AGENT_SESSION_RESTART_CONTINUATION_NOTE)] + expect(notices(noteOnly)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + // An unconfirmed continuation whose turn opened before its warning: the warning is about that + // turn, so the cut keeps its own notice beside it. + it('keeps the notice when a restart note follows a turn the continuation opened', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', { state: 'running' }), + restartNote(AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, 'warning') + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + // A send after the cut, whose new agent died before its turn opened: that exit is about the send. + it('lets no exit row about the conversation explain a cut a message was sent after', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + exitRow('provider-exit:s:6:gen', THREAD) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + // A row about the conversation speaks for the cut it follows, never across a turn that finished. + it('lets a later exit row about the conversation explain no cut a finished turn came after', () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + turn('t2', 'u2', { state: 'completed', outcome: 'success' }), + exitRow('provider-exit:s:5:gen', THREAD) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + // Read before the startup reconcile, the cut settled unverifiable; the user sent u2; the reconcile + // then proved the old owner dead and wrote its row about the conversation after u2. That row is + // about the cut, not the send, so it stays the one explanation. + it("takes an owner's proven death as the explanation of the cut though a message was sent since", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user('u2'), + exitRow('stale-session:s:death-3-2000', THREAD) + ] + expect(withNativeChatCutTurnNotices(items)).toBe(items) + }) + + it("does not take a failed start's row as the explanation of an earlier cut", () => { + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + item( + agentJournalItemKey(structuredAgentSessionStartFailureRowIdentity('gen-2')), + exitWords(), + THREAD + ) + ] + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: inTurn('t1') }]) + }) + + 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 messages = projectStructuredAgentSessionMessages(derived, [], [], { + rejectedInPlace: true + }) + 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(turnKeys[notice]).toBe('u1') + }) + + // A message Orca accepted and then failed to deliver after the cut is drawn in place as not sent, + // in no turn; the cut keeps its notice in its own turn, and the exit row about that send does + // not stand in for it. + it('keeps the notice in its turn beside a later message drawn as not sent', () => { + const sent = agentJournalSubmissionKey('client-u2') + const items = [ + user('u1'), + turn('t1', 'u1', CUT), + reply('a1', inTurn('t1')), + user(sent), + exitRow('provider-exit:s:7:gen', THREAD) + ] + const submissions: AgentJournalSubmission[] = [ + { + clientMessageId: 'client-u2', + fence: 2, + payloadFingerprint: 'fp-u2', + dispatchState: 'rejected', + providerItemId: null, + reason: NOTICE, + submittedAt: 50, + resolvedAt: 60 + } + ] + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + const messages = projectStructuredAgentSessionMessages(derived, [], submissions, { + rejectedInPlace: true + }) + const { turnKeys } = nativeChatTurnMembership(messages, { items: derived, submissions }) + + const notice = messages.findIndex((message) => message.id.includes('cut-turn-notice')) + expect(turnKeys[notice]).toBe('u1') + expect(messages.find((message) => message.id === sent)).toMatchObject({ unsent: true }) + }) + + it('reads a journal from a host that states no scope, without making it look like one that does', () => { + const items = [ + user('u1', null), + turn('t1', 'u1', CUT, null), + reply('a1', null), + user('u2', null) + ] + + const derived = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + + expect(notices(items)).toEqual([{ index: 3, text: NOTICE, scope: undefined }]) + expect(hostStatesTurnScopes(derived)).toBe(false) + }) + + it('reads the older status-row turn record, and keeps each notice the same object across reads', () => { + const record = item( + 't1', + { + kind: 'status', + text: '', + turnLifecycle: { + turnId: 't1', + userItemId: 'u1', + state: 'interrupted', + startedAt: 1_000, + completedAt: 2_000 + } + }, + null + ) + const items = [user('u1', null), record] + + const first = withNativeChatCutTurnNotices(items) + const second = withNativeChatCutTurnNotices([...items]) + + expect(first).toHaveLength(3) + expect(second[2]).toBe(first[2]) + }) +}) diff --git a/src/shared/native-chat-cut-turn-notice.ts b/src/shared/native-chat-cut-turn-notice.ts new file mode 100644 index 000000000000..ddf486363084 --- /dev/null +++ b/src/shared/native-chat-cut-turn-notice.ts @@ -0,0 +1,212 @@ +// The one row that says a turn was cut short when the agent stopped without anyone asking (a crash, +// a quit, an eviction, a teardown). Derived on read from the journal, never stored: the turn record +// 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. + +import { agentSessionFailureFact, 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' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' +import { agentTurnVerdict } 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' +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( + items: readonly AgentJournalRenderItem[], + turnIndex: number, + statesScopes: boolean +): number { + const turnItemId = items[turnIndex]!.itemId + let last = turnIndex + for (let index = turnIndex + 1; index < items.length; index += 1) { + const item = items[index]! + if (statesScopes) { + if (item.turnScope?.kind === 'turn' && item.turnScope.turnItemId === turnItemId) { + last = index + } + continue + } + if (readAgentJournalTurn(item.body) && isRootAgentJournalItem(item)) { + break + } + if (!(item.body.kind === 'message' && item.body.role === 'user')) { + last = index + } + } + return last +} + +const noticeCache = new WeakMap< + AgentJournalRenderItem, + { + after: AgentJournalRenderItem + statesScopes: boolean + agentName: string | undefined + notice: AgentJournalRenderItem + } +>() + +function cutTurnNotice( + turnItem: AgentJournalRenderItem, + after: AgentJournalRenderItem, + statesScopes: boolean, + context: CutTurnNoticeContext +): AgentJournalRenderItem { + const cached = noticeCache.get(turnItem) + if ( + cached?.after === after && + cached.statesScopes === statesScopes && + cached.agentName === context.agentName + ) { + return cached.notice + } + const notice: AgentJournalRenderItem = { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: `${CUT_TURN_NOTICE_ROW}${turnItem.itemId}` + }), + revision: 0, + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...context, + surface: 'row' + }), + tone: 'error' + }, + // Just after the turn's last row and before anything the journal wrote next. + sequence: after.sequence, + sequenceIndex: (after.sequenceIndex ?? 0) + 0.5, + observedAt: readAgentJournalTurn(turnItem.body)?.completedAt ?? after.observedAt, + // 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 }) + 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. + */ +export function withNativeChatCutTurnNotices( + items: readonly AgentJournalRenderItem[], + context: CutTurnNoticeContext = {} +): readonly AgentJournalRenderItem[] { + const explained = explainedCutTurns(items) + const statesScopes = hostStatesTurnScopes(items) + const noticesAfter = new Map() + 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) + noticesAfter.set(last, [...(noticesAfter.get(last) ?? []), notice]) + } + }) + if (noticesAfter.size === 0) { + return items + } + return items.flatMap((item, index) => [item, ...(noticesAfter.get(index) ?? [])]) +} diff --git a/src/shared/native-chat-turn-status.test.ts b/src/shared/native-chat-turn-status.test.ts index 3b70e57bdafb..08557aef9a0a 100644 --- a/src/shared/native-chat-turn-status.test.ts +++ b/src/shared/native-chat-turn-status.test.ts @@ -42,9 +42,9 @@ describe('describeNativeChatTurnStatus', () => { it.each([ ['cancellation', 'interruptedAfter'], ['superseded', 'interruptedAfter'], - // A turn anything but the user cut short reads as a failure does. - ['interruption', 'failedAfter'], ['failure', 'failedAfter'], + // A crash or restart cut it off: the turn reads finished and the chat's notice row says why. + ['interruption', 'workedFor'], ['success', 'workedFor'], ['unconfirmed', 'workedFor'], [undefined, 'workedFor'] @@ -108,6 +108,24 @@ describe('formatNativeChatTurnStatusLabel', () => { 'Worked for 3m 4s' ) }) + + // 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', () => { + expect( + formatNativeChatTurnStatusLabel({ + workedSeconds: 222, + elapsedSeconds: 0, + verdict: 'interruption' + }) + ).toBe('Worked for 3m 42s') + expect( + formatNativeChatTurnStatusLabel({ + workedSeconds: 222, + elapsedSeconds: 0, + verdict: 'cancellation' + }) + ).toBe('Interrupted after 3m 42s') + }) }) describe('reduceNativeChatTurnTiming', () => { diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index f1dbea00947d..446c489c350c 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -42,7 +42,8 @@ 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 or a turn anything else cut short reads failed. */ + * 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. */ verdict?: AgentTurnOutcome }): { key: 'workingFor' | 'workedFor' | 'interruptedAfter' | 'failedAfter' @@ -61,9 +62,9 @@ function settledTurnStatusKey( case 'superseded': return 'interruptedAfter' case 'failure': - case 'interruption': return 'failedAfter' case 'success': + case 'interruption': case 'unconfirmed': case undefined: return 'workedFor'