From e6e0fec2911446d01ca8fb899650c539f159666e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:25:34 -0700 Subject: [PATCH 01/29] feat(native-chat): say in the chat why Orca stopped a reply, and offer Continue When the Orca that runs a structured chat (this computer or a paired server) quits, updates or crashes mid-reply, the chat's stopped row now names the cause and the machine, and a Continue button sends the existing restart continuation for that cut turn, with or without a restart offer. - Host: a quit/update writes one turn-scoped row for the turn its stop cut, in today's words, with an optional `orcaStop` cause on the providerExited fact; restart adjudication stamps how the previous runtime ended on the deaths it proves (crash, or the quit/update it began), so the crash row names it too. Older clients keep their single row. - Host: agentSession.continueInterrupted, capability-gated, rechecks under the session lock that the chat still sits on that cut, so a second click or a retry sends nothing. - Client: the row's copy names the cause and machine; Continue sits above the composer. --- ...gent-session-dead-generation-settlement.ts | 8 +- .../structured-agent-session-host-lifetime.ts | 28 ++- ...tructured-agent-session-host-stop-event.ts | 6 +- ...ctured-agent-session-host-teardown.test.ts | 22 ++ .../structured-agent-session-host-teardown.ts | 28 ++- ...t-session-interrupted-continuation.test.ts | 134 +++++++++++ ...-agent-session-interrupted-continuation.ts | 118 ++++++++++ .../structured-agent-session-orca-stop-row.ts | 114 ++++++++++ ...t-session-restart-continuation-envelope.ts | 6 +- ...ured-agent-session-restart-continuation.ts | 16 +- ...ent-session-restart-failure-filing.test.ts | 4 +- ...t-session-restart-offer-withdrawal.test.ts | 5 +- ...ed-agent-session-restart-ownership.test.ts | 4 +- ...d-agent-session-restart-resume-contract.ts | 39 ++++ ...tured-agent-session-restart-resume-host.ts | 40 +--- ...red-agent-session-shutdown-cut-row.test.ts | 212 ++++++++++++++++++ ...session-death-evidence-persistence.test.ts | 4 +- src/main/runtime/agent-session-record-rows.ts | 2 + .../agent-session-record-store-file.ts | 4 + ...agent-session-restart-lease-transitions.ts | 10 +- .../agent-session-restart-reconciliation.ts | 7 +- ...nt-session-runtime-teardown-record.test.ts | 107 +++++++++ .../agent-session-runtime-teardown-record.ts | 49 ++++ ...structured-agent-session-restart-resume.ts | 17 +- .../structured-agent-session-schemas.ts | 1 + .../NativeChatInterruptedContinue.test.tsx | 123 ++++++++++ .../NativeChatInterruptedContinue.tsx | 83 +++++++ .../native-chat/NativeChatNoticeRow.test.tsx | 50 ++++- .../native-chat/NativeChatNoticeRow.tsx | 38 +++- .../NativeChatStructuredSession.tsx | 52 +++-- .../native-chat-host-label-context.ts | 9 + ...use-structured-agent-session-host-label.ts | 13 ++ src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/fr.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- src/shared/agent-session-failure.ts | 26 ++- src/shared/agent-session-record.ts | 10 +- src/shared/native-chat-orca-stop-cut.test.ts | 120 ++++++++++ src/shared/native-chat-orca-stop-cut.ts | 86 +++++++ src/shared/protocol-version.ts | 6 + .../rpc-params-catalog.generated.ts | 2 + .../structured-agent-session-params.ts | 6 + .../structured-agent-session-host-fixture.ts | 4 +- ...ructured-agent-session-surface-manifest.ts | 8 + 47 files changed, 1594 insertions(+), 99 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts create mode 100644 src/main/runtime/agent-session-runtime-teardown-record.test.ts create mode 100644 src/main/runtime/agent-session-runtime-teardown-record.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-host-label-context.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-host-label.ts create mode 100644 src/shared/native-chat-orca-stop-cut.test.ts create mode 100644 src/shared/native-chat-orca-stop-cut.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 639b34cb7955..b5371cc05197 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 @@ -38,6 +38,7 @@ import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' +import { providerExitedRowWords } from './structured-agent-session-orca-stop-row' import { withdrawCodexSendsNoTurnOpenedFor, type UnopenedSendJournal @@ -265,13 +266,10 @@ export async function settleStaleStructuredAgentSessionState(input: { 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. + // that the provider stopped, and how Orca ended when the provider died with it. body: { kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { - ...input.failureTextContext, - surface: 'row' - }), + ...providerExitedRowWords(input.failureTextContext, evidence.runtimeEnd), tone: 'error' }, turnScope: runningRootTurnScope(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 2c312ef17e84..f5d3c566b8d6 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,6 +34,12 @@ import { import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' import { settleStructuredAgentSessionDeadGeneration } from './structured-agent-session-dead-generation-settlement' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' +import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +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, @@ -150,6 +156,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( const owed = owedStop(session, cause, ending.retry === true) session.owesProviderChildWindDown = owed const stopping = session.child + // Read before the kill: the turn a quit's stop may cut. + const quitCuts = 'quit' in ending && ending.quit ? runningRootTurnItemId(session.journal) : null const eviction: StructuredAgentSessionEvictionContext = { sessionId, // The retry must not re-stop a child the adapter already proved gone, so this stays honest. @@ -204,6 +212,21 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // Without the cause the log names the step and nothing else. throw new Error('dead generation work settlement failed', { cause: settled.error }) } + if ('quit' in ending && ending.quit) { + const record = context.deps.store.getRecord(sessionId) + await recordStructuredAgentSessionShutdownCut({ + journal: session.journal, + sessionId, + fence, + generation: owed?.generation ?? 'unknown', + turnItemId: quitCuts, + trigger: ending.quit, + ...(record + ? { failureTextContext: structuredAgentSessionFailureWordsContext(record) } + : {}), + logger: context.deps.logger + }) + } }, releaseLease: async () => { if (owed) { @@ -322,7 +345,8 @@ export async function evictOwnedStructuredAgentSessions( context: StructuredAgentSessionLifetimeContext & { serialize: (sessionId: string, task: () => Promise) => Promise }, - retainOnFailure: Set + retainOnFailure: Set, + trigger: AgentSessionResumeTrigger = 'quit' ): Promise { const ownedSessionIds = [...context.sessions] .filter(([, session]) => owedProviderChildWindDown(session) !== undefined) @@ -340,7 +364,7 @@ export async function evictOwnedStructuredAgentSessions( await context.serialize(sessionId, () => stopStructuredAgentSessionAgentUnderSerialize(context, sessionId, { cause: 'evict', - quit: true + quit: trigger }) ) retainOnFailure.delete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 70cfd07bb7a6..6a16562abcdd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -9,16 +9,18 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause' import { sentSinceStop } from './structured-agent-session-queued-stop' +import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' /** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its * own step (`recorded` names its reason); any other stop names the reason its event records, with - * the host's text for it. Quit writes none: its resume marker's trigger records why. */ + * the host's text for it. Quit writes none: its row about a turn it cut says why. */ export type StructuredAgentSessionStopEnding = ( | { recorded: 'user-stop' } | { cause: Exclude reason?: string - quit?: true + /** The app is quitting, and why: a turn this stop cuts gets a row naming it. */ + quit?: AgentSessionResumeTrigger /** The idle sweep judged the agent resting (`owesWork`): a send it retires unanswered is * no work its event records. */ resting?: true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index e6f62d6d780c..e165791eda3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -30,6 +30,7 @@ describe('structured agent-session host teardown', () => { recordResumeMarkers: noop }) expect(phases.map((phase) => phase.name)).toEqual([ + 'record-runtime-teardown', 'begin-resume-markers', 'dispose-idle-sweep', 'stop-lease-renewal', @@ -40,6 +41,27 @@ describe('structured agent-session host teardown', () => { ]) }) + it('records that this runtime is quitting first, and a failed record never stops the quit', async () => { + const order: string[] = [] + const phases = structuredAgentSessionHostTeardownPhases({ + logger: createStructuredAgentSessionLogger(), + idleSweep: { dispose: noop }, + runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, + tasks: { drainAttaches: noop }, + evictOwnedSessions: async () => void order.push('evict'), + beginResumeMarkers: () => {}, + recordResumeMarkers: noop, + recordRuntimeTeardown: () => { + order.push('record') + throw new Error('disk full') + } + }) + for (const phase of phases) { + await phase.run() + } + expect(order).toEqual(['record', 'evict']) + }) + it("derives quit's child-eviction bound from every provider's supervised close", () => { // Eviction drains the sink for the resume offer before it stops the child, inside one bound. // Each close's tree-kill fallback is deliberately outside it: once main exits the supervisor diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 65bf219cf79d..924a8e929078 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -16,6 +16,7 @@ import { type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { withTimeout } from '../../../shared/promise-timeout-fallback' +import { recordAgentSessionRuntimeTeardown } from '../../runtime/agent-session-runtime-teardown-record' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -67,9 +68,24 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { /** Opens this teardown's witnesses; each session's own is taken as eviction stops its child. */ beginResumeMarkers: () => void recordResumeMarkers: () => Promise + /** The durable word that this runtime began quitting, so the next start never calls an + * unfinished quit a crash. */ + recordRuntimeTeardown?: () => void logger: StructuredAgentSessionLogger }): StructuredAgentSessionTeardownPhase[] { return [ + { + name: 'record-runtime-teardown', + run: () => { + try { + collaborators.recordRuntimeTeardown?.() + } catch { + collaborators.logger.warn('recording that this runtime is quitting failed', { + scope: 'teardown-runtime-record' + }) + } + } + }, { name: 'begin-resume-markers', run: () => { @@ -171,8 +187,18 @@ export async function flushStructuredAgentSessionHost( stopped: context.restartResume.confirmStopped } }, - retainSessionIds + retainSessionIds, + context.trigger ), + recordRuntimeTeardown: () => { + if (!context.deps.journalDatabase.readOnly) { + recordAgentSessionRuntimeTeardown( + context.deps.journalDatabase.db, + context.trigger, + context.now() + ) + } + }, beginResumeMarkers: () => context.restartResume.beginTeardown(context.trigger), recordResumeMarkers: context.restartResume.recordMarkers, logger: context.deps.logger diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts new file mode 100644 index 000000000000..d3a695ab2777 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts @@ -0,0 +1,134 @@ +// Continue on a reply an Orca stop cut off: the restart continuation, bound to the cut turn and +// re-checked under the session lock, so it works with or without a restart offer and never sends +// twice. + +import { expect, it, vi } from 'vitest' +import { + AGENT_SESSION_RESTART_CONTINUATION_MESSAGE, + AGENT_SESSION_RESTART_CONTINUATION_NOTE, + restartContinuationMessage +} from '../../../shared/agent-session-restart-continuation' +import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' +import { CALLER, envelope } from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' +import { + interruptedRestart, + statusNotes +} from './structured-agent-session-restart-interruption-test-harness' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +type Restarted = Awaited> + +async function cutTurn(host: StructuredAgentSessionHost): Promise { + const { items } = await host.journalSnapshot(SESSION) + const cut = latestNativeChatOrcaStopCut(items, []) + if (!cut) { + throw new Error('the restart left no Orca-stop cut') + } + return cut.turnItemId +} + +function sentTexts(dispatch: Restarted['dispatch']): string[] { + return dispatch.mock.calls.map(([input]) => + input.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('') + ) +} + +it('continues with no restart offer, as Orca itself, and the chat says so', async () => { + const { host, dispatch } = await interruptedRestart() + // What a crash, a paired server's restart or a dismissed offer leaves: no offer at all. + await host.restartResume.dismiss() + expect(await host.restartResume.list()).toEqual([]) + const send = vi.spyOn(host, 'send') + + const answer = await host.restartResume.continueInterrupted(SESSION, await cutTurn(host)) + + expect(answer).toMatchObject({ + sessionId: SESSION, + outcome: expect.stringMatching(/pending|continued/) + }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts(dispatch)).toEqual([AGENT_SESSION_RESTART_CONTINUATION_MESSAGE]) + // Sent under Orca's own caller, so it reads as Orca's and leaves a queue pause alone. + expect(send.mock.calls[0]?.[0]).toEqual({ + callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER + }) + await vi.waitFor(async () => + expect(JSON.stringify(await statusNotes(host))).toContain( + AGENT_SESSION_RESTART_CONTINUATION_NOTE + ) + ) +}) + +it('continues a chat that still has its restart offer, in its words, and the offer is spent', async () => { + const { host, dispatch, marker } = await interruptedRestart() + expect(await host.restartResume.list()).toHaveLength(1) + + await host.restartResume.continueInterrupted(SESSION, await cutTurn(host)) + + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts(dispatch)).toEqual([restartContinuationMessage(marker!)]) + expect(await host.restartResume.list()).toEqual([]) +}) + +it('sends once for two clicks at the same moment', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + + const answers = await Promise.all([ + host.restartResume.continueInterrupted(SESSION, turnItemId), + host.restartResume.continueInterrupted(SESSION, turnItemId) + ]) + + expect(answers.filter((answer) => answer.outcome === 'superseded')).toHaveLength(1) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) +}) + +it('sends nothing for a retry after an answer that was lost', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + await host.restartResume.continueInterrupted(SESSION, turnItemId) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + + // The client never heard back, so it asks again for the same cut. + const retry = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(retry).toEqual({ sessionId: SESSION, outcome: 'superseded' }) + expect(dispatch).toHaveBeenCalledOnce() +}) + +it("sends nothing once the user's own message came first", async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + const body = hostTestMessage('A new request') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'superseded' }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts(dispatch)).toEqual(['A new request']) +}) + +it('sends nothing for a turn that is not the cut the chat sits on', async () => { + const { host, dispatch } = await interruptedRestart() + + const answer = await host.restartResume.continueInterrupted(SESSION, 'some-other-turn') + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'superseded' }) + expect(dispatch).not.toHaveBeenCalled() +}) + +it('continues when the restart offer cannot be read', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + vi.spyOn(host.deps.recoveryCapsule!, 'list').mockRejectedValue(new Error('unreadable')) + + await host.restartResume.continueInterrupted(SESSION, turnItemId) + + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts new file mode 100644 index 000000000000..9ef2606d831b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts @@ -0,0 +1,118 @@ +// Continue, pressed in a chat whose reply an Orca stop cut off: the restart continuation, bound to +// that cut turn rather than to a restart offer. An offer, when one survives, only picks the words; +// none is needed, so a crash or a paired server's restart continues the same way. + +import { createHash, randomUUID } from 'node:crypto' +import { withTimeout } from '../../../shared/promise-timeout-fallback' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' +import { + continuationDeps, + RestartContinuationSupersededError, + startStructuredAgentSessionContinuation, + type StructuredAgentSessionContinuationHost, + type StructuredAgentSessionContinuationOutcome +} from './structured-agent-session-restart-continuation' + +export type StructuredAgentSessionInterruptedContinuationOutcome = { + sessionId: string + /** `superseded`: the chat is no longer sitting on that cut (a message, or another Continue, came + * first), so nothing was sent. */ + outcome: StructuredAgentSessionContinuationOutcome['outcome'] | 'superseded' + reason?: string +} + +const hex16 = (parts: readonly unknown[]): string => + createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16) + +/** Dated by the click, as the ledger requires of a new id; tagged with the cut it continues. */ +function interruptedContinuationId( + sessionId: string, + turnItemId: string, + actionAt: number, + nonce: string +): string { + return `${Math.trunc(actionAt).toString().padStart(13, '0')}-${hex16([sessionId, turnItemId])}${hex16([sessionId, turnItemId, nonce])}` +} + +/** + * Sends the continuation when, at acceptance inside the session lock, the chat's latest turn is + * still `turnItemId`, cut by an Orca stop with nothing sent since. A second Continue (another + * client, a retry after a lost answer) finds the first one's message there and sends nothing. + */ +export async function continueInterruptedStructuredAgentSessionTurn( + host: Omit, + input: { + sessionId: string + turnItemId: string + /** The chat's restart offer, when one could be read: its words cover lost background work. */ + offer: Pick | null + nonce: string + } +): Promise { + const { sessionId, turnItemId } = input + const stillCut = (): boolean => { + const journal = host.sessions.get(sessionId)?.journal + return ( + journal !== undefined && + latestNativeChatOrcaStopCut(journal.snapshot().items, journal.submissions())?.turnItemId === + turnItemId + ) + } + const continuationId = interruptedContinuationId(sessionId, turnItemId, host.now(), input.nonce) + try { + const started = await startStructuredAgentSessionContinuation( + continuationDeps(host, stillCut), + sessionId, + input.offer ?? {}, + continuationId + ) + if ('verdict' in started) { + // Handed to its agent: the provider's answer can take a whole turn, and the chat's own note + // reports it, so the click is answered now. + void started.verdict().catch(() => { + host.logger.warn('settling an interrupted-turn continuation failed', { + scope: 'interrupted-continuation', + sessionId + }) + }) + return { sessionId, outcome: 'pending' } + } + return { + sessionId, + outcome: started.done.outcome, + ...(started.done.reason ? { reason: started.done.reason } : {}) + } + } catch (error) { + if (error instanceof RestartContinuationSupersededError) { + return { sessionId, outcome: 'superseded' } + } + throw error + } +} + +/** Reading the offer only picks the words, so a slow recovery file never holds Continue. */ +const OFFER_READ_TIMEOUT_MS = 2_000 + +/** Continue for one host: its offer, when one can be read in time, picks the words. */ +export function createInterruptedContinuation( + host: Omit, + readOffers: () => Promise +): ( + sessionId: string, + turnItemId: string +) => Promise { + return async (sessionId, turnItemId) => { + const offers = await withTimeout( + readOffers().catch(() => []), + OFFER_READ_TIMEOUT_MS, + [] + ) + return continueInterruptedStructuredAgentSessionTurn(host, { + sessionId, + turnItemId, + offer: offers.find((offer) => offer.sessionId === sessionId) ?? null, + nonce: randomUUID() + }) + } +} 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 new file mode 100644 index 000000000000..972142c43410 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts @@ -0,0 +1,114 @@ +// 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`). + +import { + agentSessionFailureFact, + isAgentSessionOrcaStopCause +} from '../../../shared/agent-session-failure' +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 } from '../../../shared/agent-session-journal-types' +import { + readAgentJournalTurn, + readAgentJournalTurnOutcome +} from '../../../shared/agent-session-turn-record' +import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' +import { orcaShutdownRowClientMessageId } from '../../../shared/native-chat-orca-stop-cut' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' + +type OrcaStopRowJournal = Pick + +/** A stopped provider's row words, with how Orca ended when the provider stopped with it. */ +export function providerExitedRowWords( + context: AgentSessionFailureWordsContext | undefined, + orcaEnd: unknown +) { + return agentSessionFailureWords( + agentSessionFailureFact( + 'providerExited', + isAgentSessionOrcaStopCause(orcaEnd) ? { orcaStop: { cause: orcaEnd } } : {} + ), + { ...context, surface: 'row' } + ) +} + +/** The root turn running as the quit stops the child: the one its stop may cut. */ +export function runningRootTurnItemId( + journal: Pick +): string | null { + return ( + journal + .snapshot() + .items.findLast( + (item) => + isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running' + )?.itemId ?? null + ) +} + +function cutByNobody(item: AgentJournalRenderItem | undefined): boolean { + const turn = item ? readAgentJournalTurn(item.body) : null + return ( + turn !== null && + agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) === + 'interruption' + ) +} + +/** + * Once the quit's stop has settled: when the turn running at the stop now reads cut with nobody + * asking (not finished, not a person's Stop), say why. Never throws: a row that cannot be written + * must not keep the quit from releasing the chat. + */ +export async function recordStructuredAgentSessionShutdownCut(input: { + journal: OrcaStopRowJournal + sessionId: string + fence: number + generation: string + turnItemId: string | null + trigger: AgentSessionResumeTrigger + failureTextContext?: AgentSessionFailureWordsContext + logger: StructuredAgentSessionLogger +}): Promise { + try { + const { turnItemId } = input + if (turnItemId === null) { + return + } + const clientMessageId = orcaShutdownRowClientMessageId( + input.sessionId, + input.fence, + input.generation + ) + const items = input.journal.snapshot().items + const written = items.some((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return identity?.provider === 'orca' && identity.clientMessageId === clientMessageId + }) + if (written || !cutByNobody(items.find((item) => item.itemId === turnItemId))) { + return + } + await input.journal.appendItem( + { provider: 'orca', clientMessageId }, + { + kind: 'status', + ...providerExitedRowWords(input.failureTextContext, input.trigger), + tone: 'error' + }, + { fence: input.fence, turnScope: { kind: 'turn', turnItemId } } + ) + } catch (error) { + input.logger.warn('recording why a quit cut a reply failed', { + scope: 'shutdown-cut-row', + sessionId: input.sessionId, + error + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts index cfa2c7b502ee..b9c2072e874f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-envelope.ts @@ -9,7 +9,9 @@ import type { AgentSessionResumeMarker } from '../../../shared/agent-session-res import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' /** The message body, built once so both the send and any test read the same text. */ -export function restartContinuationBody(marker: AgentSessionResumeMarker): AgentJournalMessageItem { +export function restartContinuationBody( + marker: Pick +): AgentJournalMessageItem { return { kind: 'message', role: 'user', @@ -57,7 +59,7 @@ export function isRestartContinuationOf( export function restartContinuationEnvelope( sessionId: string, fence: number, - marker: AgentSessionResumeMarker, + marker: Pick, continuationId: string ): { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } { const body = restartContinuationBody(marker) 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 1a66b5561031..e5d6fc2c1bb7 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 @@ -85,10 +85,11 @@ export type StructuredAgentSessionContinuationHost = { } /** Binds one continuation to the host: the superseded check before dispatch, the settlement - * waiter for the verdict, and the journal note that attributes the send to Orca. */ -export function restartContinuationDeps( - host: StructuredAgentSessionContinuationHost, - marker: AgentSessionResumeMarker + * waiter for the verdict, and the journal note that attributes the send to Orca. `stillWanted` is + * asked at acceptance, inside the session lock, so the first message accepted since decides. */ +export function continuationDeps( + host: Omit, + stillWanted: () => boolean ): StructuredAgentSessionContinuationDeps { return { currentFence: host.conversationFence, @@ -96,7 +97,7 @@ export function restartContinuationDeps( host.send({ ...input, beforeRun: () => { - if (!host.stillResumable(marker)) { + if (!stillWanted()) { throw new RestartContinuationSupersededError() } } @@ -212,7 +213,8 @@ export type StartedStructuredAgentSessionContinuation = export async function startStructuredAgentSessionContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker, + /** Only what picks the message's words; a continuation with no offer passes none. */ + marker: Pick, /** This action's continuation, as its offer recorded it. */ continuationId: string ): Promise { @@ -291,7 +293,7 @@ async function noteNotContinued( async function sendContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker, + marker: Pick, continuationId: string ): Promise { const fence = deps.currentFence(sessionId) 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 4602630960a9..c99cc945d31c 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 @@ -45,8 +45,8 @@ it('files nothing for a chat the user moved on in before its attempt, and spends const capsule = new AgentSessionRecoveryCapsule(root) 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. + // Only the quit's own row about the cut: no note took over, so it stays the turn's one notice. + expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE]) expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index b155674babf0..89e039689a5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -17,6 +17,7 @@ import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { interruptedRestart, + QUIT_CUT_NOTICE, statusNotes } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' @@ -385,8 +386,8 @@ it("refuses an automatic continuation when the user's message was accepted first expect(result.continued).toMatchObject([{ outcome: 'refused', reason: SUPERSEDED }]) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce(), COLD_START) expect(sentTexts(dispatch)).toEqual(['A new request']) - // Nothing the user did failed: no row reached the reader, and nothing is kept. - expect(await statusNotes(host)).toEqual([]) + // Nothing the user did failed: no row but the quit's own about the cut, and nothing is kept. + expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE]) expect( events.some( (event) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 4019582e1fde..d9a449a15b4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -16,6 +16,7 @@ import { import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, + QUIT_CUT_NOTICE, startAgent, statusNotes, supersededRefusal, @@ -354,7 +355,8 @@ it("refuses a continuation quietly when the user's own message was accepted firs failed: [] }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(await statusNotes(host)).toEqual([]) + // The quit's own row about the cut, and nothing about the refused continuation. + expect(await statusNotes(host)).toEqual([QUIT_CUT_NOTICE]) expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts new file mode 100644 index 000000000000..9b07c3a66fd6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts @@ -0,0 +1,39 @@ +// What a host's restart-resume surface offers: teardown's offer recording, the offer list and its +// actions, and Continue on a cut reply. + +import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +import type { createInterruptedContinuation } from './structured-agent-session-interrupted-continuation' +import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' +import type { + StructuredAgentSessionResumeCandidate, + StructuredAgentSessionResumeFailure +} from './structured-agent-session-restart-resume-set' + +export type StructuredAgentSessionRestartResume = { + /** Teardown: begin, then per session a snapshot right before its child stops and a confirmation + * once the stop is proven, then one write of the confirmed offers. */ + beginTeardown: (trigger: AgentSessionResumeTrigger) => void + captureBeforeStop: (sessionId: string) => void + confirmStopped: (sessionId: string) => void + recordMarkers: () => Promise + list: () => Promise + /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ + listFailures: () => Promise + continueAfterRestart: ( + sessionIds: readonly string[] | undefined, + owner: string + ) => Promise<{ + resumed: StructuredAgentSessionResumeOutcome[] + continued: StructuredAgentSessionContinuationOutcome[] + sessions?: StructuredAgentSessionResumeCandidate[] + failed?: StructuredAgentSessionResumeFailure[] + }> + /** Named sessions forget their offer or failure; unnamed, every record this host + * lists goes (a newer Orca's stay). */ + dismiss: (sessionIds?: readonly string[]) => Promise + /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ + onAgentStarted: (sessionId: string) => void + /** Continue on a reply an Orca stop cut off, offer or not; the send itself retires any offer. */ + continueInterrupted: ReturnType +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 83dedb1709d3..e1f0c2acf5c2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -9,10 +9,7 @@ import { } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import type { - AgentSessionResumeMarker, - AgentSessionResumeTrigger -} from '../../../shared/agent-session-resume-marker' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { createNewerOrcaChats, @@ -33,13 +30,15 @@ import { StructuredAgentSessionResumeAdmission, type StructuredAgentSessionResumeOutcome } from './structured-agent-session-restart-resume-runner' +import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-contract' import { - restartContinuationDeps, + continuationDeps, startStructuredAgentSessionContinuation, type StructuredAgentSessionContinuationHost, type StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' import { restartContinuationId } from './structured-agent-session-restart-continuation-envelope' +import { createInterruptedContinuation } from './structured-agent-session-interrupted-continuation' import { createStructuredAgentSessionRestartOfferWithdrawal, type StructuredAgentSessionRestartOfferSession @@ -52,31 +51,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo type LiveSession = StructuredAgentSessionRestartOfferSession -export type StructuredAgentSessionRestartResume = { - /** Teardown: begin, then per session a snapshot right before its child stops and a confirmation - * once the stop is proven, then one write of the confirmed offers. */ - beginTeardown: (trigger: AgentSessionResumeTrigger) => void - captureBeforeStop: (sessionId: string) => void - confirmStopped: (sessionId: string) => void - recordMarkers: () => Promise - list: () => Promise - /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ - listFailures: () => Promise - continueAfterRestart: ( - sessionIds: readonly string[] | undefined, - owner: string - ) => Promise<{ - resumed: StructuredAgentSessionResumeOutcome[] - continued: StructuredAgentSessionContinuationOutcome[] - sessions?: StructuredAgentSessionResumeCandidate[] - failed?: StructuredAgentSessionResumeFailure[] - }> - /** Named sessions forget their offer or failure; unnamed, every record this host - * lists goes (a newer Orca's stay). */ - dismiss: (sessionIds?: readonly string[]) => Promise - /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ - onAgentStarted: (sessionId: string) => void -} +export type { StructuredAgentSessionRestartResume } export function createStructuredAgentSessionRestartResume( deps: { @@ -248,7 +223,7 @@ export function createStructuredAgentSessionRestartResume( // never starts more agents at once than the runner allows; the provider's answer comes after. const action = await run(sessionIds, owner, async (marker, continuationId) => { const started = await startStructuredAgentSessionContinuation( - restartContinuationDeps(continuationHost, marker), + continuationDeps(continuationHost, () => continuationHost.stillResumable(marker)), marker.sessionId, marker, continuationId @@ -328,6 +303,7 @@ export function createStructuredAgentSessionRestartResume( await revealEvery() }), continueAfterRestart, - onAgentStarted: withdrawal.onAgentStarted + onAgentStarted: withdrawal.onAgentStarted, + continueInterrupted: createInterruptedContinuation(continuationHost, readMarkers) } } 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 new file mode 100644 index 000000000000..877db0953eb6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts @@ -0,0 +1,212 @@ +// 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. + +import { beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +import { readAgentSessionFailureFact } from '../../../shared/agent-session-failure' +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' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { recordAgentSessionRuntimeTeardown } from '../../runtime/agent-session-runtime-teardown-record' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +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. */ +let providerEnd: Pick + +beforeEach(() => { + const state = hostTestState() + providerEnd = { state: 'interrupted', completedAt: HOST_TEST_NOW } + host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store: state.store, + adapter: { + ...adapter(), + closeSession: async () => { + state.acquire.mock.calls.at(-1)?.[0].events.appendItem( + CUT_TURN, + { + kind: 'turn', + turnId: 'cut-turn', + startedAt: 1_000, + requestedAt: 1_000, + ...providerEnd + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return true + } + }, + journalDatabase: openTestJournalHostDatabase(state.root), + recoveryCapsule: new AgentSessionRecoveryCapsule(state.root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => HOST_TEST_NOW + }) + replaceHostTestState({ store: state.store, host }) +}) + +async function runningTurn(): Promise { + await attach() + const events = hostTestState().acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + events.appendItem( + CUT_TURN, + { kind: 'turn', turnId: 'cut-turn', state: 'running', startedAt: 1_000, requestedAt: 1_000 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) +} + +/** The Orca that ran the turn dies with no teardown; the next one starts over the same files and + * finds the turn's owner gone. */ +async function restartAfterDeath(): Promise { + const { root } = hostTestState() + const store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(root), + recoveryCapsule: new AgentSessionRecoveryCapsule(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => HOST_TEST_NOW + 60_000 + }) + replaceHostTestState({ store, host }) + await host.reconcileRestartLeases() +} + +async function reread() { + await host.restoreReadableSessions([SESSION]) + const { items } = await host.journalSnapshot(SESSION) + const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId + const stopRows = items.filter( + (item) => + item.body.kind === 'status' && + readAgentSessionFailureFact(item.body.failure)?.orcaStop !== undefined + ) + const readerErrors = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap( + (item) => (item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : []) + ) + return { items, turnItemId, stopRows, readerErrors } +} + +describe('the row a quit writes for the reply it cut', () => { + it.each(['update', 'quit'] as const)('names a %s on the cut turn, once', async (trigger) => { + await runningTurn() + + await host.flushAllStreamedEvents({ trigger }) + + const { items, turnItemId, stopRows, readerErrors } = await reread() + expect(stopRows).toHaveLength(1) + const [row] = stopRows + expect(row?.body).toMatchObject({ + kind: 'status', + text: LEGACY_TEXT, + tone: 'error', + failure: { kind: 'providerExited', 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]) + expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: trigger }) + }) + + it('writes nothing for a turn the provider finished during the stop', async () => { + providerEnd = { state: 'completed', outcome: 'success', completedAt: 1_500 } + await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'update' }) + + const { stopRows, readerErrors } = await reread() + expect(stopRows).toEqual([]) + expect(readerErrors).toEqual([]) + }) + + it("writes nothing for a turn a person's Stop already ended", async () => { + providerEnd = { state: 'interrupted', outcome: 'cancellation', completedAt: 1_500 } + await runningTurn() + + await host.flushAllStreamedEvents({ trigger: 'update' }) + + const { stopRows } = await reread() + expect(stopRows).toEqual([]) + }) + + it('never calls an idle eviction a restart', async () => { + await runningTurn() + + await host.close(SESSION, 'evict') + + const { items, stopRows, readerErrors } = await reread() + expect(stopRows).toEqual([]) + // The cut keeps today's derived notice, and offers no Continue. + expect(readerErrors).toEqual([LEGACY_TEXT]) + expect(latestNativeChatOrcaStopCut(items, [])).toBeNull() + }) +}) + +describe('the row a restart writes for a reply its Orca died in', () => { + it('says Orca stopped unexpectedly when the Orca before began no quit', async () => { + await runningTurn() + + await restartAfterDeath() + + const { items, turnItemId, stopRows, readerErrors } = await reread() + expect(stopRows).toHaveLength(1) + expect(stopRows[0]?.body).toMatchObject({ + text: LEGACY_TEXT, + failure: { kind: 'providerExited', orcaStop: { cause: 'crash' } } + }) + expect(readerErrors).toEqual([LEGACY_TEXT]) + expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' }) + }) + + it('names the update an unfinished quit began, never a crash', async () => { + await runningTurn() + // The quit wrote its first word, then died before it stopped the agent. + recordAgentSessionRuntimeTeardown( + openTestJournalHostDatabase(hostTestState().root).db, + 'update', + HOST_TEST_NOW + ) + + await restartAfterDeath() + + const { stopRows } = await reread() + expect(stopRows.map((row) => row.body)).toMatchObject([ + { failure: { kind: 'providerExited', orcaStop: { cause: 'update' } } } + ]) + }) +}) diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts index 9904ca7994f4..54503fee111b 100644 --- a/src/main/runtime/agent-session-death-evidence-persistence.test.ts +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -58,7 +58,9 @@ describe('death evidence on disk', () => { detail: 'recorded pid absent on host', observedAt: 90_000, ownerFence: 7, - lastProvenAliveAt: LAST_RENEWED_AT + lastProvenAliveAt: LAST_RENEWED_AT, + // The runtime before this one recorded no quit, so its owner died with a crash. + runtimeEnd: 'crash' } expect(crashed.getRecord(SESSION)?.lease.deathEvidence).toEqual(evidence) expect((await open()).getRecord(SESSION)?.lease.deathEvidence).toEqual(evidence) diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index be6f70f06e7d..9936279f474c 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -24,6 +24,7 @@ import { isReadableRetiredAgentSessionClaimKey } from './agent-session-store-row-rules' import { AgentSessionTabTable, type PersistedAgentSessionTab } from './agent-session-tab-table' +import { takeAgentSessionRuntimeTeardown } from './agent-session-runtime-teardown-record' /** "Never recorded" and "recorded, now empty" differ, and #17439's legacy fallback needs the first; * no row count can say which. */ @@ -163,6 +164,7 @@ export function loadAgentSessionStoreRows( } else if (table.sessionIds().length > 0) { state.unrecordedSessionTabs = table } + state.previousRuntimeEnd = takeAgentSessionRuntimeTeardown(db) return state } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 79e65388fbd0..89f551fa2cd3 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -17,6 +17,7 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' +import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-failure' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -40,6 +41,9 @@ export type AgentSessionStoreState = { sessionTabs: AgentSessionTabTable | null /** Tab rows an index never recorded holds (chats opened while the import was owed). */ unrecordedSessionTabs?: AgentSessionTabTable + /** How the runtime before this one ended, read once at load; null when that cannot be told. + * Restart adjudication stamps it on the deaths it proves. */ + previousRuntimeEnd?: AgentSessionOrcaStopCause | null } export type LoadedAgentSessionStore = { diff --git a/src/main/runtime/agent-session-restart-lease-transitions.ts b/src/main/runtime/agent-session-restart-lease-transitions.ts index b4269d21c677..075b19f64cbd 100644 --- a/src/main/runtime/agent-session-restart-lease-transitions.ts +++ b/src/main/runtime/agent-session-restart-lease-transitions.ts @@ -10,6 +10,7 @@ import { adjudicateAgentSessionRestart, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-failure' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { withLease } from './agent-session-lease-transitions' @@ -18,6 +19,8 @@ export function applyAgentSessionRestartAdjudication(args: { record: AgentSessionRecord probe: AgentSessionOwnerProbe now: number + /** How the runtime that held every loaded lease ended; null when that is unknown. */ + runtimeEnd?: AgentSessionOrcaStopCause | null }): AgentSessionRecord { const { record } = args const adjudication = adjudicateAgentSessionRestart({ @@ -49,7 +52,12 @@ export function applyAgentSessionRestartAdjudication(args: { unreconciled: false, lastRenewedAt: args.now, handoffOperationId: null, - deathEvidence: adjudication.evidence + // A lease still owned at load was held by the runtime before this one, so the owner died + // with it: the evidence says how that runtime ended, which a provider's own exit never does. + deathEvidence: + adjudication.evidence && args.runtimeEnd + ? { ...adjudication.evidence, runtimeEnd: args.runtimeEnd } + : adjudication.evidence }) } // Parking in recovery proves nothing alive, so `lastRenewedAt` keeps the pre-crash proof. diff --git a/src/main/runtime/agent-session-restart-reconciliation.ts b/src/main/runtime/agent-session-restart-reconciliation.ts index b3447ec85c85..5a1c8ffdb699 100644 --- a/src/main/runtime/agent-session-restart-reconciliation.ts +++ b/src/main/runtime/agent-session-restart-reconciliation.ts @@ -48,7 +48,12 @@ export function applyAgentSessionRestartProbes( ) { continue } - const next = applyAgentSessionRestartAdjudication({ record, probe: probed.probe, now }) + const next = applyAgentSessionRestartAdjudication({ + record, + probe: probed.probe, + now, + runtimeEnd: state.previousRuntimeEnd ?? null + }) state.records.set(sessionId, next) reconciled.set(sessionId, next) } diff --git a/src/main/runtime/agent-session-runtime-teardown-record.test.ts b/src/main/runtime/agent-session-runtime-teardown-record.test.ts new file mode 100644 index 000000000000..8cb494a1d697 --- /dev/null +++ b/src/main/runtime/agent-session-runtime-teardown-record.test.ts @@ -0,0 +1,107 @@ +// Whether a chat's owner died with a crashed Orca or with an Orca that was quitting: only restart +// adjudication can say the owner died with the runtime before this one, and the word that runtime +// left at the start of its quit says how it ended. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionDeathEvidence } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' +import { + recordAgentSessionRuntimeTeardown, + takeAgentSessionRuntimeTeardown +} from './agent-session-runtime-teardown-record' + +const SESSION = 'session-alpha-1' + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-runtime-teardown-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +/** A lease the runtime before this one still held: its owner is what restart adjudicates. */ +async function seedOwnedLease(): Promise { + await seedTestAgentSessionRecordStore(directory, { + records: [agentSessionRecordFixture(agentSessionLeaseFixture())] + }) +} + +async function seedReleasedLease(deathEvidence: AgentSessionDeathEvidence): Promise { + await seedTestAgentSessionRecordStore(directory, { + records: [ + agentSessionRecordFixture( + agentSessionLeaseFixture({ + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + deathEvidence + }) + ) + ] + }) +} + +async function restartEvidence(): Promise { + const store = await openTestAgentSessionRecordStore(directory) + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) + return store.getRecord(SESSION)?.lease.deathEvidence ?? null +} + +describe('how the runtime that held a dead owner ended', () => { + it('is a crash when that runtime never began a quit', async () => { + await seedOwnedLease() + expect(await restartEvidence()).toMatchObject({ kind: 'pid-absent', runtimeEnd: 'crash' }) + }) + + it.each(['update', 'quit'] as const)( + 'is the %s that runtime began, when its quit did not finish', + async (trigger) => { + await seedOwnedLease() + recordAgentSessionRuntimeTeardown(openTestJournalHostDatabase(directory).db, trigger, 50_000) + + expect(await restartEvidence()).toMatchObject({ kind: 'pid-absent', runtimeEnd: trigger }) + } + ) + + it('describes only the runtime just before: the next load takes it', () => { + const db = openTestJournalHostDatabase(directory).db + recordAgentSessionRuntimeTeardown(db, 'update', 50_000) + + expect(takeAgentSessionRuntimeTeardown(db)).toBe('update') + expect(takeAgentSessionRuntimeTeardown(db)).toBe('crash') + }) + + it('never calls a quit it cannot read a crash', () => { + const db = openTestJournalHostDatabase(directory).db + db.prepare('INSERT INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( + 'runtime_teardown', + JSON.stringify({ trigger: 'logout', startedAt: 1 }) + ) + expect(takeAgentSessionRuntimeTeardown(db)).toBeNull() + }) + + it("leaves a provider's own death, observed while Orca ran, without a runtime end", async () => { + const observed: AgentSessionDeathEvidence = { + kind: 'exit-observed', + detail: 'observed process exit', + observedAt: 40_000, + ownerFence: 7 + } + await seedReleasedLease(observed) + expect(await restartEvidence()).toEqual(observed) + }) +}) diff --git a/src/main/runtime/agent-session-runtime-teardown-record.ts b/src/main/runtime/agent-session-runtime-teardown-record.ts new file mode 100644 index 000000000000..bd2624ef8e33 --- /dev/null +++ b/src/main/runtime/agent-session-runtime-teardown-record.ts @@ -0,0 +1,49 @@ +// The durable word that a runtime began quitting, so the next start can tell an Orca that crashed +// from one whose quit did not finish. Written first thing in teardown and taken at the next load: +// any lease still owned then was granted by the runtime that ran just before, after its own load +// had taken the previous word, so a word found at load is always that runtime's. + +import { + isAgentSessionOrcaStopCause, + type AgentSessionOrcaStopCause +} from '../../shared/agent-session-failure' +import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' +import type Database from '../sqlite/sync-database' + +const RUNTIME_TEARDOWN_KEY = 'runtime_teardown' + +export function recordAgentSessionRuntimeTeardown( + db: Database.Database, + trigger: AgentSessionResumeTrigger, + now: number +): void { + db.prepare('INSERT OR REPLACE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( + RUNTIME_TEARDOWN_KEY, + JSON.stringify({ trigger, startedAt: now }) + ) +} + +/** + * How the previous runtime ended, and forgets it: its quit's trigger, or 'crash' when it began none. + * Null when that cannot be told — the read failed, or the word names a trigger this build does not + * know — so nothing is ever called a crash on a failed read. + */ +export function takeAgentSessionRuntimeTeardown( + db: Database.Database +): AgentSessionOrcaStopCause | null { + try { + const row = db + .prepare('SELECT value FROM agent_session_store_meta WHERE key = ?') + .get(RUNTIME_TEARDOWN_KEY) + if (!row) { + return 'crash' + } + db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(RUNTIME_TEARDOWN_KEY) + const value: unknown = typeof row.value === 'string' ? JSON.parse(row.value) : null + const trigger = + typeof value === 'object' && value !== null && 'trigger' in value ? value.trigger : null + return trigger !== 'crash' && isAgentSessionOrcaStopCause(trigger) ? trigger : null + } catch { + return null + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index a48f897a005e..fb58f7a2aad9 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -11,7 +11,11 @@ import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' -import { RestartResumableParams, RestartResumeParams } from './structured-agent-session-schemas' +import { + ContinueInterruptedParams, + RestartResumableParams, + RestartResumeParams +} from './structured-agent-session-schemas' export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ defineMethod({ @@ -63,6 +67,17 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ ) } }), + defineMethod({ + // Continue on a reply an Orca stop cut off: the same continuation, bound to the cut turn + // instead of an offer. Clients gate it on AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY. + name: 'agentSession.continueInterrupted', + params: ContinueInterruptedParams, + handler: async (params, ctx) => { + await ensureStructuredHostInstalled(ctx) + const host = requireStructuredHost(ctx) + return host.restartResume.continueInterrupted(params.sessionId, params.turnItemId) + } + }), defineMethod({ // Reattach only, no send. Reattaching is nothing now — an agent starts only for work — so this // answers that nothing was resumed. No Orca surface calls it, but it is a PUBLISHED wire diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 5930fa1e80fa..aaec3769963b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -5,6 +5,7 @@ export { AttachParams, CancelParams, + ContinueInterruptedParams, ConversationCommandParams, CreateIntentParams, CreateParams, diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx new file mode 100644 index 000000000000..4ed542850373 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + supported: true +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('@/runtime/structured-agent-session-host-capability', () => ({ + useStructuredAgentSessionHostCapability: () => mocks.supported +})) + +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { NativeChatInterruptedContinue } from './NativeChatInterruptedContinue' + +const TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 }) +const PAIRED: RuntimeClientTarget = { kind: 'environment', environmentId: 'studio-mac' } + +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.', + tone: 'error', + failure: agentSessionFailureFact('providerExited', { orcaStop: { cause: 'crash' } }) + } + } +] + +function renderContinue( + overrides: Partial[0]> = {} +) { + const onError = vi.fn() + render( + + ) + return { onError } +} + +beforeEach(() => { + mocks.supported = true + mocks.call.mockReset() +}) + +afterEach(cleanup) + +describe('Continue on a reply an Orca stop cut off', () => { + it("asks the chat's own host, paired or local, to continue that cut turn", async () => { + mocks.call.mockResolvedValue({ outcome: 'pending' }) + renderContinue() + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + + expect(mocks.call).toHaveBeenCalledExactlyOnceWith(PAIRED, 'agentSession.continueInterrupted', { + sessionId: 'session-1', + turnItemId: TURN + }) + // Gone once asked: the journal shows what came of it. + expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + }) + + it('is not offered by a host without the operation', () => { + mocks.supported = false + renderContinue() + expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + }) + + it('is not offered once anything was sent, or while the agent works', () => { + renderContinue({ submissions: [{ dispatchState: 'pending' }] }) + expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + cleanup() + renderContinue({ isWorking: true }) + expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + }) + + it('is not offered for a cut no Orca stop explains', () => { + renderContinue({ journalItems: cutChat.slice(0, 1) }) + expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + }) + + it('says so and offers it again when the request fails', async () => { + mocks.call.mockRejectedValue(new Error('offline')) + const { onError } = renderContinue() + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + + await waitFor(() => + expect(onError).toHaveBeenLastCalledWith( + "Couldn't continue this chat. Try again, or send a message." + ) + ) + expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx new file mode 100644 index 000000000000..f1968c759009 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -0,0 +1,83 @@ +import { useMemo, useState } from 'react' +import { Play } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { useStructuredAgentSessionHostCapability } from '@/runtime/structured-agent-session-host-capability' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { latestNativeChatOrcaStopCut } from '../../../../shared/native-chat-orca-stop-cut' +import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' + +type ContinueAnswer = { outcome?: string } + +/** + * Continue, while the chat's latest turn is a reply an Orca stop cut off and nothing was sent since. + * Shown only for a host that has the operation; an older host writes no row naming the stop, so it + * never has a cut to show this for. The chat itself reports what the continuation did. + */ +export function NativeChatInterruptedContinue({ + target, + sessionId, + journalItems, + submissions, + isWorking, + onError +}: { + target: RuntimeClientTarget + sessionId: string + journalItems: readonly AgentJournalRenderItem[] + submissions: readonly Pick[] + isWorking: boolean + onError: (message: string | null) => void +}): React.JSX.Element | null { + const supported = useStructuredAgentSessionHostCapability( + target, + AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY + ) + const cut = useMemo( + () => latestNativeChatOrcaStopCut(journalItems, submissions), + [journalItems, submissions] + ) + // The cut this client already asked to continue: hidden until the journal shows what came of it. + const [asked, setAsked] = useState(null) + if (!supported || !cut || isWorking || asked === cut.turnItemId) { + return null + } + const turnItemId = cut.turnItemId + const onContinue = (): void => { + setAsked(turnItemId) + onError(null) + void callStructuredAgentSession(target, 'agentSession.continueInterrupted', { + sessionId, + turnItemId + }).then( + (answer) => { + // Refused before anything was sent: the host's note says why, and Continue may try again. + if (answer.outcome === 'refused') { + setAsked((current) => (current === turnItemId ? null : current)) + } + }, + () => { + setAsked((current) => (current === turnItemId ? null : current)) + onError( + translate( + 'components.native-chat.interruptedContinue.failed', + "Couldn't continue this chat. Try again, or send a message." + ) + ) + } + ) + } + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index b9e453573b82..cc2df1ec6dc9 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -5,18 +5,64 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types' import { MessageRow } from './NativeChatMessageRow' +import { NativeChatHostLabelContext } from './native-chat-host-label-context' afterEach(cleanup) -function renderStatus(body: AgentJournalStatusItem) { +function renderStatus(body: AgentJournalStatusItem, hostLabel: string | null = null) { const [message] = projectStructuredItemsToNativeChat([ { itemId: 'notice', sequence: 1, revision: 1, observedAt: 1, body } ]) return render( - + + + ) } +const LEGACY_TEXT = + 'Codex stopped while this response was in progress. You can continue in this conversation.' + +function orcaStopRow(cause: string): AgentJournalStatusItem { + return { + kind: 'status', + text: LEGACY_TEXT, + tone: 'error', + failure: { kind: 'providerExited', orcaStop: { cause } } + } +} + +describe('the row an Orca stop leaves', () => { + it.each([ + ['update', 'Stopped: Orca on studio-mac restarted for an update'], + ['quit', 'Stopped: Orca on studio-mac was restarted'], + ['crash', 'Stopped: Orca on studio-mac stopped unexpectedly'] + ])('names a %s and the machine', (cause, headline) => { + renderStatus(orcaStopRow(cause), 'studio-mac') + const row = screen.getByText(new RegExp(headline)) + expect(row).toHaveTextContent( + 'The reply was cut off partway through. Continue, and the agent first checks whether its last step finished.' + ) + expect(row.parentElement?.parentElement).toHaveClass('text-destructive') + expect(screen.queryByText(LEGACY_TEXT)).toBeNull() + }) + + 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', () => { + renderStatus(orcaStopRow('update'), null) + expect(screen.getByText(LEGACY_TEXT)).toBeInTheDocument() + }) + + it('keeps the host words for a stop Orca did not cause', () => { + renderStatus({ ...orcaStopRow('update'), failure: { kind: 'providerExited' } }, 'studio-mac') + expect(screen.getByText(LEGACY_TEXT)).toBeInTheDocument() + }) +}) + describe('notice rows', () => { it('renders compaction as a centered separator', () => { renderStatus({ kind: 'status', text: 'Context compacted', presentation: 'compaction' }) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index b63eb73abd17..83ff93780b28 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -10,7 +10,9 @@ import { isAgentSessionHostStatusPresentation, type AgentSessionHostStatusPresentation } from '../../../../shared/agent-session-host-status-rows' +import type { AgentSessionOrcaStopCause } from '../../../../shared/agent-session-failure' import type { NativeChatTextBlock } from '../../../../shared/native-chat-types' +import { useNativeChatHostLabel } from './native-chat-host-label-context' import { ProviderFrameRow } from './NativeChatTranscriptChrome' const HOST_STATUS_WORDS: Record string> = { @@ -26,6 +28,29 @@ const HOST_STATUS_WORDS: Record string ) } +const ORCA_STOP_HEADLINES: Record string> = { + update: (machine) => + translate( + 'components.native-chat.notices.orcaStopUpdate', + 'Stopped: Orca on {{machine}} restarted for an update', + { machine } + ), + quit: (machine) => + translate( + 'components.native-chat.notices.orcaStopQuit', + 'Stopped: Orca on {{machine}} was restarted', + { + machine + } + ), + crash: (machine) => + translate( + 'components.native-chat.notices.orcaStopCrash', + 'Stopped: Orca on {{machine}} stopped unexpectedly', + { machine } + ) +} + export function NativeChatNoticeRow({ block, onLinkClick, @@ -35,6 +60,7 @@ export function NativeChatNoticeRow({ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean }): React.JSX.Element { + const hostLabel = useNativeChatHostLabel() if (block.presentation === 'compaction') { const label = translate('components.native-chat.notices.compaction', 'Context compacted') return ( @@ -86,6 +112,16 @@ export function NativeChatNoticeRow({ ) } + // The host's row about an Orca stop: named only when this chat knows its machine, else the + // generic words the host wrote. + const orcaStop = block.failure?.kind === 'providerExited' ? block.failure.orcaStop : undefined + const text = + orcaStop && hostLabel + ? `${ORCA_STOP_HEADLINES[orcaStop.cause](hostLabel)}\n${translate( + 'components.native-chat.notices.orcaStopDetail', + 'The reply was cut off partway through. Continue, and the agent first checks whether its last step finished.' + )}` + : block.text const tone = block.tone const Icon = tone === 'warning' @@ -107,7 +143,7 @@ export function NativeChatNoticeRow({ >
{Icon ?
{block.providerFrame ? ( ) : ( - + + + )} {readFailedFinally ? null : ( @@ -289,6 +295,14 @@ export function NativeChatStructuredSession( agentLabel={agentLabel} onRetry={provisionalLaunch.retry} /> + {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} (null) + +export function useNativeChatHostLabel(): string | null { + return useContext(NativeChatHostLabelContext) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-host-label.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-host-label.ts new file mode 100644 index 000000000000..39e540d50df7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-host-label.ts @@ -0,0 +1,13 @@ +import { selectExecutionHostDisplayLabel } from '@/lib/execution-host-display-label' +import { executionHostIdForStructuredTarget } from '@/runtime/structured-agent-session-owner' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { useAppStore } from '../../store' + +/** What Orca calls the chat's host; null for a paired server this client has no name for, whose + * raw id is not a name. */ +export function useStructuredAgentSessionHostLabel(target: RuntimeClientTarget): string | null { + return useAppStore((state) => { + const label = selectExecutionHostDisplayLabel(state, executionHostIdForStructuredTarget(target)) + return target.kind === 'environment' && label === target.environmentId ? null : label + }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25087a71ea2b..5b4f5fb2aaa8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17852,7 +17852,11 @@ "details": "Details", "plan": "Plan", "historyRepaired": "Part of this chat's history couldn't be loaded.", - "historyItemTooLarge": "This part of the chat was too large to show." + "historyItemTooLarge": "This part of the chat was too large to show.", + "orcaStopUpdate": "Stopped: Orca on {{machine}} restarted for an update", + "orcaStopQuit": "Stopped: Orca on {{machine}} was restarted", + "orcaStopCrash": "Stopped: Orca on {{machine}} stopped unexpectedly", + "orcaStopDetail": "The reply was cut off partway through. Continue, and the agent first checks whether its last step finished." }, "composer": { "imageUnsupported": "Image paste is not supported for this agent.", @@ -18207,7 +18211,11 @@ "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", "structuredSessionHostUnreachable": "Could not reach {{value0}}", - "structuredSessionHostUnreachableDescription": "Orca did not open a {{value0}} chat. Check the connection to the server and try again." + "structuredSessionHostUnreachableDescription": "Orca did not open a {{value0}} chat. Check the connection to the server and try again.", + "interruptedContinue": { + "continue": "Continue", + "failed": "Couldn't continue this chat. Try again, or send a message." + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ec2d25bbb344..80f030b58971 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17932,7 +17932,11 @@ "details": "Detalles", "plan": "Plan", "historyRepaired": "No se pudo cargar parte del historial de este chat.", - "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla." + "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla.", + "orcaStopUpdate": "Detenido: Orca en {{machine}} se reinició para actualizarse", + "orcaStopQuit": "Detenido: Orca en {{machine}} se reinició", + "orcaStopCrash": "Detenido: Orca en {{machine}} se detuvo inesperadamente", + "orcaStopDetail": "La respuesta se cortó a medias. Continúa y el agente comprobará primero si su último paso terminó." }, "providerFrame": { "byteLength": "{{value0}} bytes" @@ -17979,7 +17983,11 @@ "structuredSessionHostDeclined": "Se abrió {{value0}} en una terminal", "structuredSessionHostDeclinedDescription": "Este servidor no puede ejecutar un chat de {{value0}} en este espacio de trabajo.", "structuredSessionHostUnreachable": "No se pudo contactar con {{value0}}", - "structuredSessionHostUnreachableDescription": "Orca no abrió un chat de {{value0}}. Comprueba la conexión con el servidor y vuelve a intentarlo." + "structuredSessionHostUnreachableDescription": "Orca no abrió un chat de {{value0}}. Comprueba la conexión con el servidor y vuelve a intentarlo.", + "interruptedContinue": { + "continue": "Continuar", + "failed": "No se pudo continuar este chat. Inténtalo de nuevo o envía un mensaje." + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 333d821d346e..b39aaa121a05 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17933,7 +17933,11 @@ "details": "Détails", "plan": "Plan", "historyRepaired": "Une partie de l'historique de ce chat n'a pas pu être chargée.", - "historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée." + "historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée.", + "orcaStopUpdate": "Arrêté : Orca sur {{machine}} a redémarré pour une mise à jour", + "orcaStopQuit": "Arrêté : Orca sur {{machine}} a été redémarré", + "orcaStopCrash": "Arrêté : Orca sur {{machine}} s'est arrêté de manière inattendue", + "orcaStopDetail": "La réponse a été interrompue en cours de route. Continuez, et l'agent vérifiera d'abord si sa dernière étape s'est terminée." }, "providerFrame": { "byteLength": "{{value0}} octets" @@ -18078,7 +18082,11 @@ "structuredSessionHostDeclined": "{{value0}} ouvert dans un terminal", "structuredSessionHostDeclinedDescription": "Ce serveur ne peut pas exécuter de discussion {{value0}} dans cet espace de travail.", "structuredSessionHostUnreachable": "Impossible de joindre {{value0}}", - "structuredSessionHostUnreachableDescription": "Orca n'a pas ouvert de discussion {{value0}}. Vérifiez la connexion au serveur et réessayez." + "structuredSessionHostUnreachableDescription": "Orca n'a pas ouvert de discussion {{value0}}. Vérifiez la connexion au serveur et réessayez.", + "interruptedContinue": { + "continue": "Continuer", + "failed": "Impossible de poursuivre cette conversation. Réessayez ou envoyez un message." + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5d1678b46d0c..d3d35455fd82 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17869,7 +17869,11 @@ "details": "詳細", "plan": "プラン", "historyRepaired": "このチャットの履歴の一部を読み込めませんでした。", - "historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。" + "historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。", + "orcaStopUpdate": "停止: {{machine}} の Orca がアップデートのため再起動しました", + "orcaStopQuit": "停止: {{machine}} の Orca が再起動されました", + "orcaStopCrash": "停止: {{machine}} の Orca が予期せず停止しました", + "orcaStopDetail": "返信は途中で中断されました。続行すると、Agent はまず最後の手順が完了したかどうかを確認します。" }, "providerFrame": { "byteLength": "{{value0}}バイト" @@ -18014,7 +18018,11 @@ "structuredSessionHostDeclined": "{{value0}} をターミナルで開きました", "structuredSessionHostDeclinedDescription": "このサーバーはこのワークスペースで {{value0}} チャットを実行できません。", "structuredSessionHostUnreachable": "{{value0}} に接続できませんでした", - "structuredSessionHostUnreachableDescription": "Orca は {{value0}} チャットを開きませんでした。サーバーへの接続を確認して、もう一度お試しください。" + "structuredSessionHostUnreachableDescription": "Orca は {{value0}} チャットを開きませんでした。サーバーへの接続を確認して、もう一度お試しください。", + "interruptedContinue": { + "continue": "続行", + "failed": "このチャットを続行できませんでした。もう一度試すか、メッセージを送信してください。" + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5cd0371e7bec..4de146a92872 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17869,7 +17869,11 @@ "details": "세부", "plan": "계획", "historyRepaired": "이 채팅 기록의 일부를 불러올 수 없습니다.", - "historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다." + "historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다.", + "orcaStopUpdate": "중지됨: {{machine}}의 Orca가 업데이트를 위해 다시 시작됨", + "orcaStopQuit": "중지됨: {{machine}}의 Orca가 다시 시작됨", + "orcaStopCrash": "중지됨: {{machine}}의 Orca가 예기치 않게 중지됨", + "orcaStopDetail": "응답이 중간에 끊겼습니다. 계속하면 에이전트가 먼저 마지막 단계가 완료되었는지 확인합니다." }, "providerFrame": { "byteLength": "{{value0}}바이트" @@ -18014,7 +18018,11 @@ "structuredSessionHostDeclined": "{{value0}}을(를) 터미널에서 열었습니다", "structuredSessionHostDeclinedDescription": "이 서버는 이 워크스페이스에서 {{value0}} 채팅을 실행할 수 없습니다.", "structuredSessionHostUnreachable": "{{value0}}에 연결할 수 없습니다", - "structuredSessionHostUnreachableDescription": "Orca가 {{value0}} 채팅을 열지 않았습니다. 서버 연결을 확인하고 다시 시도하세요." + "structuredSessionHostUnreachableDescription": "Orca가 {{value0}} 채팅을 열지 않았습니다. 서버 연결을 확인하고 다시 시도하세요.", + "interruptedContinue": { + "continue": "계속", + "failed": "이 채팅을 계속할 수 없습니다. 다시 시도하거나 메시지를 보내세요." + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3ce2dbdb7ba2..2c5f329cc071 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17834,7 +17834,11 @@ "details": "细节", "plan": "计划", "historyRepaired": "此聊天的部分历史记录无法加载。", - "historyItemTooLarge": "聊天的这一部分过大,无法显示。" + "historyItemTooLarge": "聊天的这一部分过大,无法显示。", + "orcaStopUpdate": "已停止:{{machine}} 上的 Orca 为更新而重新启动", + "orcaStopQuit": "已停止:{{machine}} 上的 Orca 已重新启动", + "orcaStopCrash": "已停止:{{machine}} 上的 Orca 意外停止", + "orcaStopDetail": "回复在中途被截断。继续后,代理会先检查其上一步是否已完成。" }, "providerFrame": { "byteLength": "{{value0}} 字节" @@ -17979,7 +17983,11 @@ "structuredSessionHostDeclined": "已在终端中打开 {{value0}}", "structuredSessionHostDeclinedDescription": "此服务器无法在此工作区中运行 {{value0}} 聊天。", "structuredSessionHostUnreachable": "无法连接到 {{value0}}", - "structuredSessionHostUnreachableDescription": "Orca 未打开 {{value0}} 聊天。请检查与服务器的连接,然后重试。" + "structuredSessionHostUnreachableDescription": "Orca 未打开 {{value0}} 聊天。请检查与服务器的连接,然后重试。", + "interruptedContinue": { + "continue": "继续", + "failed": "无法继续此聊天。请重试,或发送一条消息。" + } }, "tab": { "bar": { diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index b08c5250a150..9aa83279a68d 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -134,6 +134,24 @@ export type AgentSessionFailureFact = { attachment?: AgentSessionAttachmentProblem /** On `providerRetrying`: why the provider is retrying. */ retry?: AgentSessionProviderRetry + /** On `providerExited`: Orca itself stopped on the chat's host, and why. */ + orcaStop?: AgentSessionOrcaStop +} + +/** Why the Orca running a chat stopped mid-reply. Open on the wire: a cause this build does not know + * reads as no cause, so the row keeps its generic words. */ +export const AGENT_SESSION_ORCA_STOP_CAUSES = ['update', 'quit', 'crash'] as const +export type AgentSessionOrcaStopCause = (typeof AGENT_SESSION_ORCA_STOP_CAUSES)[number] +export type AgentSessionOrcaStop = { cause: AgentSessionOrcaStopCause } + +export function isAgentSessionOrcaStopCause(value: unknown): value is AgentSessionOrcaStopCause { + return AGENT_SESSION_ORCA_STOP_CAUSES.some((cause) => cause === value) +} + +function readOrcaStop(value: unknown): AgentSessionOrcaStop | undefined { + return isRecord(value) && isAgentSessionOrcaStopCause(value.cause) + ? { cause: value.cause } + : undefined } /** A fact as a row stores it: its kind may be one a newer host added, so only @@ -165,6 +183,7 @@ export function agentSessionFailureFact( refusal?: AgentSessionRefusalReference attachment?: AgentSessionAttachmentProblem retry?: AgentSessionProviderRetry + orcaStop?: AgentSessionOrcaStop } = {} ): AgentSessionFailureFact & { kind: TKind } { // Re-bounded here, so no writer can store more than the cap however it built the detail. @@ -176,7 +195,8 @@ export function agentSessionFailureFact( ...(detail ? { detail } : {}), ...(extra.refusal ? { refusal: extra.refusal } : {}), ...(extra.attachment ? { attachment: extra.attachment } : {}), - ...(extra.retry ? { retry: extra.retry } : {}) + ...(extra.retry ? { retry: extra.retry } : {}), + ...(extra.orcaStop ? { orcaStop: { cause: extra.orcaStop.cause } } : {}) } } @@ -233,11 +253,13 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure const refusal = readAgentSessionRefusalReference(value.refusal) const attachment = readAttachmentProblem(value.attachment) const retry = readProviderRetry(value.retry) + const orcaStop = readOrcaStop(value.orcaStop) return agentSessionFailureFact(value.kind, { ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), ...(refusal ? { refusal } : {}), ...(attachment ? { attachment } : {}), - ...(retry ? { retry } : {}) + ...(retry ? { retry } : {}), + ...(orcaStop ? { orcaStop } : {}) }) } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index b8280cf332da..ffd28d3f7bc6 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -94,6 +94,11 @@ export type AgentSessionDeathEvidence = { * proved it alive. Only a probe's proof records it: absent on a surface-release exit, a failed * start, and evidence older builds wrote. */ lastProvenAliveAt?: number + /** Only from restart adjudication, which proves the owner died with the Orca runtime that held + * it: how that runtime ended ('quit' | 'update' when it had begun a quit, else 'crash'). Absent + * for a provider that died on its own. A string, since a newer build may write a cause this one + * does not know; read it with `isAgentSessionOrcaStopCause`. */ + runtimeEnd?: string } export type AgentSessionLease = { @@ -285,7 +290,7 @@ function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeath return false } const evidence = value as Partial - const { observedAt, lastProvenAliveAt, ownerFence } = evidence + const { observedAt, lastProvenAliveAt, ownerFence, runtimeEnd } = evidence return ( (evidence.kind === 'exit-observed' || evidence.kind === 'pid-absent' || @@ -298,7 +303,8 @@ function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeath (lastProvenAliveAt === undefined || (Number.isSafeInteger(lastProvenAliveAt) && lastProvenAliveAt >= 0 && - lastProvenAliveAt <= observedAt)) + lastProvenAliveAt <= observedAt)) && + (runtimeEnd === undefined || isBoundedString(runtimeEnd, MAX_ID_LENGTH)) ) } diff --git a/src/shared/native-chat-orca-stop-cut.test.ts b/src/shared/native-chat-orca-stop-cut.test.ts new file mode 100644 index 000000000000..b9e3c0549447 --- /dev/null +++ b/src/shared/native-chat-orca-stop-cut.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact, readAgentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import { agentJournalItemKey } from './agent-session-journal-item-key' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' +import { + latestNativeChatOrcaStopCut, + orcaShutdownRowClientMessageId +} from './native-chat-orca-stop-cut' + +const LEGACY_TEXT = + 'Codex stopped while this response was in progress. You can continue in this conversation.' + +const turnId = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 }) + +function userMessage(sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: `user-${sequence}` }), + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + } +} + +function cutTurn(outcome?: string): AgentJournalRenderItem { + return { + itemId: turnId, + revision: 2, + sequence: 2, + observedAt: 2, + body: { + kind: 'turn', + turnId: 'cut', + state: 'interrupted', + startedAt: 1, + completedAt: 5, + ...(outcome ? { outcome } : {}) + } + } +} + +/** The host's row, as a host this build or a newer one writes it. */ +function stopRow(failure: unknown, scoped = true): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: orcaShutdownRowClientMessageId('session-1', 3, 'gen-1') + }), + revision: 1, + sequence: 3, + observedAt: 6, + body: { kind: 'status', text: LEGACY_TEXT, tone: 'error', failure }, + ...(scoped ? { turnScope: { kind: 'turn' as const, turnItemId: turnId } } : {}) + } +} + +const updateFact = agentSessionFailureFact('providerExited', { orcaStop: { cause: 'update' } }) + +describe('the cause on a stopped row', () => { + it('is read when known and dropped when a newer host names one this build does not know', () => { + expect(readAgentSessionFailureFact(updateFact)?.orcaStop).toEqual({ cause: 'update' }) + expect( + readAgentSessionFailureFact({ kind: 'providerExited', orcaStop: { cause: 'power-loss' } }) + ).toEqual({ kind: 'providerExited' }) + }) + + it("keeps today's sentence, so a client that reads no cause prints the same row", () => { + expect(agentSessionFailureWords(updateFact, { agentName: 'Codex', surface: 'row' }).text).toBe( + LEGACY_TEXT + ) + }) +}) + +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", () => { + // An older client keeps only the kind (as this build does for an unknown cause). + const items = [userMessage(1), cutTurn(), stopRow({ kind: 'providerExited' })] + const read = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + expect(read).toBe(items) + expect(read.filter((item) => item.body.kind === 'status')).toHaveLength(1) + }) +}) + +describe('the cut Continue answers', () => { + it('is the latest turn when an Orca stop cut it and nothing was sent since', () => { + expect( + latestNativeChatOrcaStopCut([userMessage(1), cutTurn(), stopRow(updateFact)], []) + ).toEqual({ turnItemId: turnId, cause: 'update' }) + }) + + it('is gone once a message follows the cut', () => { + expect( + latestNativeChatOrcaStopCut( + [userMessage(1), cutTurn(), stopRow(updateFact), userMessage(4)], + [] + ) + ).toBeNull() + }) + + it('is gone while a send is on its way', () => { + expect( + latestNativeChatOrcaStopCut( + [userMessage(1), cutTurn(), stopRow(updateFact)], + [{ dispatchState: 'pending' }] + ) + ).toBeNull() + }) + + it("is none for a stop with no Orca cause, a person's Stop, or an unscoped row", () => { + expect( + latestNativeChatOrcaStopCut([cutTurn(), stopRow({ kind: 'providerExited' })], []) + ).toBeNull() + expect( + latestNativeChatOrcaStopCut([cutTurn('cancellation'), stopRow(updateFact)], []) + ).toBeNull() + expect(latestNativeChatOrcaStopCut([cutTurn(), stopRow(updateFact, false)], [])).toBeNull() + }) +}) diff --git a/src/shared/native-chat-orca-stop-cut.ts b/src/shared/native-chat-orca-stop-cut.ts new file mode 100644 index 000000000000..74604fd51259 --- /dev/null +++ b/src/shared/native-chat-orca-stop-cut.ts @@ -0,0 +1,86 @@ +// 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 +// under the session lock before Continue sends anything. + +import { + readAgentSessionFailureFact, + type AgentSessionOrcaStopCause +} from './agent-session-failure' +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' +import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' +import { agentTurnVerdict } from './agent-turn-outcome' + +/** The row a quit or update writes for the turn it cut, keyed by the child the quit stopped, so a + * retried stop of that child writes no second row. The prefix is one older clients already read + * as explaining a cut turn, so they add no row of their own beside it. */ +export function orcaShutdownRowClientMessageId( + sessionId: string, + fence: number, + generation: string +): string { + 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 { + if (item.body.kind !== 'status') { + return null + } + const fact = readAgentSessionFailureFact(item.body.failure) + return fact?.kind === 'providerExited' ? (fact.orcaStop?.cause ?? null) : null +} + +export type NativeChatOrcaStopCut = { + turnItemId: string + cause: AgentSessionOrcaStopCause +} + +/** + * The latest root turn, when an Orca stop cut it and nothing was sent since: no message after it, + * and no send still on its way. Null otherwise. + */ +export function latestNativeChatOrcaStopCut( + items: readonly AgentJournalRenderItem[], + submissions: readonly Pick[] +): NativeChatOrcaStopCut | null { + if (submissions.some((submission) => submission.dispatchState === 'pending')) { + return null + } + let turnIndex = -1 + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]! + if (!isRootAgentJournalItem(item)) { + continue + } + if (item.body.kind === 'message' && item.body.role === 'user') { + return null + } + if (readAgentJournalTurn(item.body)) { + turnIndex = index + break + } + } + const turnItem = items[turnIndex] + const turn = turnItem ? readAgentJournalTurn(turnItem.body) : null + if ( + !turnItem || + !turn || + agentTurnVerdict({ state: turn.state, outcome: readAgentJournalTurnOutcome(turn) }) !== + 'interruption' + ) { + return null + } + for (const item of items) { + const cause = + item.turnScope?.kind === 'turn' && item.turnScope.turnItemId === turnItem.itemId + ? orcaStopCauseOfRow(item) + : null + if (cause) { + return { turnItemId: turnItem.itemId, cause } + } + } + return null +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 4715f220a46b..cf70e9d3c318 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -226,6 +226,11 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = // gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const +// Why: a client may call `agentSession.continueInterrupted` (Continue on a reply an Orca stop cut +// off) only on a host that has it; an older host also writes no row naming that stop, so a client +// never offers Continue there. +export const AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY = + 'agent-session.continue-interrupted.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = @@ -397,6 +402,7 @@ export const RUNTIME_CAPABILITIES = [ // turn starts, so a client may gate on either. AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, + AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index d6d81433e8e0..60511a2e8d2e 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -474,6 +474,7 @@ import { SshTarget } from './ssh-params' import { AttachParams, CancelParams, + ContinueInterruptedParams, ConversationCommandParams, CreateParams, CreateSupportParams, @@ -588,6 +589,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.cancel': CancelParams, 'agentSession.close': OptionsParams, 'agentSession.commands': OptionsParams, + 'agentSession.continueInterrupted': ContinueInterruptedParams, 'agentSession.conversationCommand': ConversationCommandParams, 'agentSession.conversationOutline': OptionsParams, 'agentSession.create': CreateParams, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index a1f2530ac5ed..d9b96f756a24 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -350,6 +350,12 @@ export const RestartResumeParams = z .object({ sessionIds: z.array(SessionId).max(MAX_RESTART_RESUME_SESSIONS).optional() }) .strict() +/** Continue on the chat's latest turn, which an Orca stop cut off. The host re-checks that this + * turn is still that cut before sending anything, so a stale or second click sends nothing. */ +export const ContinueInterruptedParams = z + .object({ sessionId: SessionId, turnItemId: z.string().min(1).max(1024) }) + .strict() + export const HistoryParams = z .object({ sessionId: SessionId, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 681c65f50c12..e65b36138e69 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -23,6 +23,7 @@ export function structuredHostStub( restartResumableDismiss: vi.fn(async () => 0), restartResumeAll: vi.fn(async () => []), restartContinueAll: vi.fn(async () => ({ resumed: [], continued: [] })), + continueInterrupted: vi.fn(async () => ({ sessionId, outcome: 'superseded' })), attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), // Attach-shaped entries take a client-supplied location, so the host is asked whether it // supports creating there. A real host always answers; leaving it unstubbed made every @@ -112,7 +113,8 @@ export function installableHost( listFailures: hostCalls.restartResumableFailures, dismiss: hostCalls.restartResumableDismiss, resume: hostCalls.restartResumeAll, - continueAfterRestart: hostCalls.restartContinueAll + continueAfterRestart: hostCalls.restartContinueAll, + continueInterrupted: hostCalls.continueInterrupted } } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a spy map standing in for the host; the dispatcher reaches only the members stubbed above, and a missing one fails the call rather than type-checking. diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index b999f63a6510..bdd58b66ee18 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -158,6 +158,12 @@ export const STRUCTURED_CALLS: { hostMethod: 'restartContinueAll', result: { resumed: [], continued: [] } }, + // Continue on a reply an Orca stop cut off. Clients call it only on a host advertising it. + { + method: 'agentSession.continueInterrupted', + hostMethod: 'continueInterrupted', + result: { sessionId: SESSION, outcome: 'superseded' } + }, { method: 'agentSession.release', hostMethod: null, result: { released: true } }, { method: 'agentSession.history', @@ -316,6 +322,8 @@ export function paramsFor(method: string): unknown { case 'agentSession.restartContinue': // Whole-surface calls: they name no session, and resume/continue narrow by an optional list. return {} + case 'agentSession.continueInterrupted': + return { sessionId: SESSION, turnItemId: 'legacy:codex:s:turn-1' } default: return { sessionId: SESSION } } From 9d2fef0e86a09aaad2348d973e31099a0742b99b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:30:40 -0700 Subject: [PATCH 02/29] test(native-chat): Continue is not held by a recovery file that never answers --- ...ured-agent-session-interrupted-continuation.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts index d3a695ab2777..80e607f4f894 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts @@ -123,6 +123,16 @@ it('sends nothing for a turn that is not the cut the chat sits on', async () => expect(dispatch).not.toHaveBeenCalled() }) +it('continues when reading the restart offer hangs', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + vi.spyOn(host.deps.recoveryCapsule!, 'list').mockReturnValue(new Promise(() => {})) + + await host.restartResume.continueInterrupted(SESSION, turnItemId) + + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) +}, 10_000) + it('continues when the restart offer cannot be read', async () => { const { host, dispatch } = await interruptedRestart() const turnItemId = await cutTurn(host) From 6801f9808b47e650cd5204c1f76c261d49b6c644 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:52:51 -0700 Subject: [PATCH 03/29] fix(native-chat): bind an Orca stop's cause to the runtime that held the agent; neutral row, Continue explains itself - The cause now rides on the host's row itself (`orcaStop` on the status row, beside today's words), the same row family and id scheme as the reopen's death row. - Each recorded owner is stamped with the Orca runtime that holds it; a death proven later (at restart, or when recovery stops a survivor) names how that runtime ended: the quit or update it began, else a crash. Owners an older build recorded, a terminal's claim, an agent that died while its Orca ran, and unreadable quit records all keep the generic words. - The quitting runtime's word is written first in teardown, before the recovery wait, through a bounded asynchronous writer apart from the chat database. - Row copy: one neutral sentence naming the machine and cause; it drops "You can continue in this conversation." while Continue is offered, and Continue's tooltip says what it does. --- ...gent-session-dead-generation-settlement.ts | 8 +- ...ctured-agent-session-host-teardown.test.ts | 22 --- .../structured-agent-session-host-teardown.ts | 25 ---- .../structured-agent-session-orca-stop-row.ts | 40 +++--- ...red-agent-session-shutdown-cut-row.test.ts | 62 +++++++-- ...session-death-evidence-persistence.test.ts | 4 +- src/main/runtime/agent-session-record-rows.ts | 2 - .../agent-session-record-store-file.ts | 8 +- ...agent-session-restart-lease-transitions.ts | 10 +- .../agent-session-restart-reconciliation.ts | 7 +- .../agent-session-runtime-attribution.test.ts | 127 ++++++++++++++++++ .../agent-session-runtime-attribution.ts | 67 +++++++++ .../agent-session-runtime-end-record.test.ts | 86 ++++++++++++ .../agent-session-runtime-end-record.ts | 89 ++++++++++++ ...nt-session-runtime-teardown-record.test.ts | 107 --------------- .../agent-session-runtime-teardown-record.ts | 49 ------- .../agent-session-store-transactions.ts | 8 +- ...ructured-agent-session-runtime-teardown.ts | 11 ++ .../NativeChatInterruptedContinue.test.tsx | 113 +++++++++------- .../NativeChatInterruptedContinue.tsx | 97 ++++++++----- .../native-chat/NativeChatNoticeRow.test.tsx | 61 ++++++--- .../native-chat/NativeChatNoticeRow.tsx | 52 +++---- .../NativeChatStructuredSession.tsx | 30 +++-- .../native-chat-host-label-context.ts | 9 -- .../native-chat-orca-stop-context.ts | 18 +++ .../native-chat-orca-stop-words.ts | 45 +++++++ src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/es.json | 11 +- src/renderer/src/i18n/locales/fr.json | 11 +- src/renderer/src/i18n/locales/ja.json | 11 +- src/renderer/src/i18n/locales/ko.json | 11 +- src/renderer/src/i18n/locales/zh.json | 11 +- src/shared/agent-session-failure.ts | 26 +--- src/shared/agent-session-journal-schemas.ts | 3 +- src/shared/agent-session-journal-types.ts | 3 + src/shared/agent-session-orca-stop.ts | 18 +++ src/shared/agent-session-record.ts | 14 +- src/shared/native-chat-orca-stop-cut.test.ts | 59 ++++---- src/shared/native-chat-orca-stop-cut.ts | 13 +- src/shared/native-chat-types.ts | 3 + .../structured-agent-session-projection.ts | 2 +- .../structured-agent-session-status-block.ts | 17 ++- 42 files changed, 861 insertions(+), 520 deletions(-) create mode 100644 src/main/runtime/agent-session-runtime-attribution.test.ts create mode 100644 src/main/runtime/agent-session-runtime-attribution.ts create mode 100644 src/main/runtime/agent-session-runtime-end-record.test.ts create mode 100644 src/main/runtime/agent-session-runtime-end-record.ts delete mode 100644 src/main/runtime/agent-session-runtime-teardown-record.test.ts delete mode 100644 src/main/runtime/agent-session-runtime-teardown-record.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-host-label-context.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-orca-stop-context.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts create mode 100644 src/shared/agent-session-orca-stop.ts 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 b5371cc05197..8899d6eb5541 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 @@ -38,7 +38,7 @@ import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' -import { providerExitedRowWords } from './structured-agent-session-orca-stop-row' +import { orcaStopRowBody } from './structured-agent-session-orca-stop-row' import { withdrawCodexSendsNoTurnOpenedFor, type UnopenedSendJournal @@ -267,11 +267,7 @@ export async function settleStaleStructuredAgentSessionState(input: { }, // The death evidence is Orca's log text, never a sentence for a person: the row says only // that the provider stopped, and how Orca ended when the provider died with it. - body: { - kind: 'status', - ...providerExitedRowWords(input.failureTextContext, evidence.runtimeEnd), - tone: 'error' - }, + body: orcaStopRowBody(input.failureTextContext, evidence.runtimeEnd), turnScope: runningRootTurnScope(items) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index e165791eda3b..e6f62d6d780c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -30,7 +30,6 @@ describe('structured agent-session host teardown', () => { recordResumeMarkers: noop }) expect(phases.map((phase) => phase.name)).toEqual([ - 'record-runtime-teardown', 'begin-resume-markers', 'dispose-idle-sweep', 'stop-lease-renewal', @@ -41,27 +40,6 @@ describe('structured agent-session host teardown', () => { ]) }) - it('records that this runtime is quitting first, and a failed record never stops the quit', async () => { - const order: string[] = [] - const phases = structuredAgentSessionHostTeardownPhases({ - logger: createStructuredAgentSessionLogger(), - idleSweep: { dispose: noop }, - runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop }, - tasks: { drainAttaches: noop }, - evictOwnedSessions: async () => void order.push('evict'), - beginResumeMarkers: () => {}, - recordResumeMarkers: noop, - recordRuntimeTeardown: () => { - order.push('record') - throw new Error('disk full') - } - }) - for (const phase of phases) { - await phase.run() - } - expect(order).toEqual(['record', 'evict']) - }) - it("derives quit's child-eviction bound from every provider's supervised close", () => { // Eviction drains the sink for the resume offer before it stops the child, inside one bound. // Each close's tree-kill fallback is deliberately outside it: once main exits the supervisor diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 924a8e929078..83afb157c905 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -16,7 +16,6 @@ import { type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { withTimeout } from '../../../shared/promise-timeout-fallback' -import { recordAgentSessionRuntimeTeardown } from '../../runtime/agent-session-runtime-teardown-record' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -68,24 +67,9 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { /** Opens this teardown's witnesses; each session's own is taken as eviction stops its child. */ beginResumeMarkers: () => void recordResumeMarkers: () => Promise - /** The durable word that this runtime began quitting, so the next start never calls an - * unfinished quit a crash. */ - recordRuntimeTeardown?: () => void logger: StructuredAgentSessionLogger }): StructuredAgentSessionTeardownPhase[] { return [ - { - name: 'record-runtime-teardown', - run: () => { - try { - collaborators.recordRuntimeTeardown?.() - } catch { - collaborators.logger.warn('recording that this runtime is quitting failed', { - scope: 'teardown-runtime-record' - }) - } - } - }, { name: 'begin-resume-markers', run: () => { @@ -190,15 +174,6 @@ export async function flushStructuredAgentSessionHost( retainSessionIds, context.trigger ), - recordRuntimeTeardown: () => { - if (!context.deps.journalDatabase.readOnly) { - recordAgentSessionRuntimeTeardown( - context.deps.journalDatabase.db, - context.trigger, - context.now() - ) - } - }, beginResumeMarkers: () => context.restartResume.beginTeardown(context.trigger), recordResumeMarkers: context.restartResume.recordMarkers, logger: context.deps.logger 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 972142c43410..3db6a686e665 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts @@ -2,10 +2,8 @@ // every client already prints for a stopped agent, with the cause beside them for clients that // name it (`AgentSessionOrcaStop`). -import { - agentSessionFailureFact, - isAgentSessionOrcaStopCause -} from '../../../shared/agent-session-failure' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { isAgentSessionOrcaStopCause } from '../../../shared/agent-session-orca-stop' import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import { agentSessionFailureWords, @@ -13,7 +11,10 @@ import { } 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 } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalStatusItem +} from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn, readAgentJournalTurnOutcome @@ -25,18 +26,21 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo type OrcaStopRowJournal = Pick -/** A stopped provider's row words, with how Orca ended when the provider stopped with it. */ -export function providerExitedRowWords( +/** The row about an owner gone from under a turn: today's words, which every client prints, and + * why Orca stopped when it was Orca, which a client that knows the cause names instead. */ +export function orcaStopRowBody( context: AgentSessionFailureWordsContext | undefined, orcaEnd: unknown -) { - return agentSessionFailureWords( - agentSessionFailureFact( - 'providerExited', - isAgentSessionOrcaStopCause(orcaEnd) ? { orcaStop: { cause: orcaEnd } } : {} - ), - { ...context, surface: 'row' } - ) +): AgentJournalStatusItem { + return { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...context, + surface: 'row' + }), + tone: 'error', + ...(isAgentSessionOrcaStopCause(orcaEnd) ? { orcaStop: { cause: orcaEnd } } : {}) + } } /** The root turn running as the quit stops the child: the one its stop may cut. */ @@ -97,11 +101,7 @@ export async function recordStructuredAgentSessionShutdownCut(input: { } await input.journal.appendItem( { provider: 'orca', clientMessageId }, - { - kind: 'status', - ...providerExitedRowWords(input.failureTextContext, input.trigger), - tone: 'error' - }, + orcaStopRowBody(input.failureTextContext, input.trigger), { fence: input.fence, turnScope: { kind: 'turn', turnItemId } } ) } catch (error) { 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 877db0953eb6..7bc4950739f6 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 @@ -2,17 +2,21 @@ // prints, with the cause beside them. A turn that finished, a person's Stop and an idle eviction // write none. -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' -import { readAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { 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' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { recordAgentSessionRuntimeTeardown } from '../../runtime/agent-session-runtime-teardown-record' +import { + agentSessionRuntimeIncarnation, + beginAgentSessionRuntimeIncarnationForTest +} from '../../runtime/agent-session-runtime-attribution' +import { recordAgentSessionRuntimeEnd } from '../../runtime/agent-session-runtime-end-record' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -91,6 +95,7 @@ async function runningTurn(): Promise { * finds the turn's owner gone. */ async function restartAfterDeath(): Promise { const { root } = hostTestState() + beginAgentSessionRuntimeIncarnationForTest() const store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), @@ -113,8 +118,7 @@ async function reread() { const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId const stopRows = items.filter( (item) => - item.body.kind === 'status' && - readAgentSessionFailureFact(item.body.failure)?.orcaStop !== undefined + 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] : []) @@ -135,7 +139,8 @@ describe('the row a quit writes for the reply it cut', () => { kind: 'status', text: LEGACY_TEXT, tone: 'error', - failure: { kind: 'providerExited', orcaStop: { cause: trigger } } + failure: { kind: 'providerExited' }, + 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. @@ -187,7 +192,7 @@ describe('the row a restart writes for a reply its Orca died in', () => { expect(stopRows).toHaveLength(1) expect(stopRows[0]?.body).toMatchObject({ text: LEGACY_TEXT, - failure: { kind: 'providerExited', orcaStop: { cause: 'crash' } } + orcaStop: { cause: 'crash' } }) expect(readerErrors).toEqual([LEGACY_TEXT]) expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' }) @@ -196,8 +201,9 @@ describe('the row a restart writes for a reply its Orca died in', () => { it('names the update an unfinished quit began, never a crash', async () => { await runningTurn() // The quit wrote its first word, then died before it stopped the agent. - recordAgentSessionRuntimeTeardown( - openTestJournalHostDatabase(hostTestState().root).db, + await recordAgentSessionRuntimeEnd( + hostTestState().root, + agentSessionRuntimeIncarnation(), 'update', HOST_TEST_NOW ) @@ -205,8 +211,40 @@ describe('the row a restart writes for a reply its Orca died in', () => { await restartAfterDeath() const { stopRows } = await reread() - expect(stopRows.map((row) => row.body)).toMatchObject([ - { failure: { kind: 'providerExited', orcaStop: { cause: 'update' } } } - ]) + expect(stopRows.map((row) => row.body)).toMatchObject([{ orcaStop: { cause: 'update' } }]) + }) +}) + +describe('an agent this Orca runs', () => { + it('is recorded as held by this runtime', async () => { + await runningTurn() + expect(hostTestState().store.getRecord(SESSION)?.lease.ownerProcess?.runtime).toBe( + agentSessionRuntimeIncarnation() + ) + }) + + it('names no Orca cause when the agent dies while Orca runs', async () => { + await runningTurn() + const child = host.collaboratorsForTests().sessions.get(SESSION)!.child! + + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + reason: 'killed', + cause: 'unexpected-exit', + fence: child.fence, + acquisitionGeneration: child.generation! + }) + + await vi.waitFor(() => + expect(hostTestState().store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ + kind: 'exit-observed' + }) + ) + expect(hostTestState().store.getRecord(SESSION)?.lease.deathEvidence).not.toHaveProperty( + 'runtimeEnd' + ) + const { stopRows } = await reread() + expect(stopRows).toEqual([]) }) }) diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts index 54503fee111b..9904ca7994f4 100644 --- a/src/main/runtime/agent-session-death-evidence-persistence.test.ts +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -58,9 +58,7 @@ describe('death evidence on disk', () => { detail: 'recorded pid absent on host', observedAt: 90_000, ownerFence: 7, - lastProvenAliveAt: LAST_RENEWED_AT, - // The runtime before this one recorded no quit, so its owner died with a crash. - runtimeEnd: 'crash' + lastProvenAliveAt: LAST_RENEWED_AT } expect(crashed.getRecord(SESSION)?.lease.deathEvidence).toEqual(evidence) expect((await open()).getRecord(SESSION)?.lease.deathEvidence).toEqual(evidence) diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index 9936279f474c..be6f70f06e7d 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -24,7 +24,6 @@ import { isReadableRetiredAgentSessionClaimKey } from './agent-session-store-row-rules' import { AgentSessionTabTable, type PersistedAgentSessionTab } from './agent-session-tab-table' -import { takeAgentSessionRuntimeTeardown } from './agent-session-runtime-teardown-record' /** "Never recorded" and "recorded, now empty" differ, and #17439's legacy fallback needs the first; * no row count can say which. */ @@ -164,7 +163,6 @@ export function loadAgentSessionStoreRows( } else if (table.sessionIds().length > 0) { state.unrecordedSessionTabs = table } - state.previousRuntimeEnd = takeAgentSessionRuntimeTeardown(db) return state } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 89f551fa2cd3..b426706aed7d 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -17,7 +17,7 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' -import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-failure' +import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -41,9 +41,9 @@ export type AgentSessionStoreState = { sessionTabs: AgentSessionTabTable | null /** Tab rows an index never recorded holds (chats opened while the import was owed). */ unrecordedSessionTabs?: AgentSessionTabTable - /** How the runtime before this one ended, read once at load; null when that cannot be told. - * Restart adjudication stamps it on the deaths it proves. */ - previousRuntimeEnd?: AgentSessionOrcaStopCause | null + /** Each earlier runtime that began a quit here, and why; read once at load. Null or absent when + * that cannot be read, which attributes no death to Orca. */ + runtimeEnds?: ReadonlyMap | null } export type LoadedAgentSessionStore = { diff --git a/src/main/runtime/agent-session-restart-lease-transitions.ts b/src/main/runtime/agent-session-restart-lease-transitions.ts index 075b19f64cbd..b4269d21c677 100644 --- a/src/main/runtime/agent-session-restart-lease-transitions.ts +++ b/src/main/runtime/agent-session-restart-lease-transitions.ts @@ -10,7 +10,6 @@ import { adjudicateAgentSessionRestart, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-failure' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { withLease } from './agent-session-lease-transitions' @@ -19,8 +18,6 @@ export function applyAgentSessionRestartAdjudication(args: { record: AgentSessionRecord probe: AgentSessionOwnerProbe now: number - /** How the runtime that held every loaded lease ended; null when that is unknown. */ - runtimeEnd?: AgentSessionOrcaStopCause | null }): AgentSessionRecord { const { record } = args const adjudication = adjudicateAgentSessionRestart({ @@ -52,12 +49,7 @@ export function applyAgentSessionRestartAdjudication(args: { unreconciled: false, lastRenewedAt: args.now, handoffOperationId: null, - // A lease still owned at load was held by the runtime before this one, so the owner died - // with it: the evidence says how that runtime ended, which a provider's own exit never does. - deathEvidence: - adjudication.evidence && args.runtimeEnd - ? { ...adjudication.evidence, runtimeEnd: args.runtimeEnd } - : adjudication.evidence + deathEvidence: adjudication.evidence }) } // Parking in recovery proves nothing alive, so `lastRenewedAt` keeps the pre-crash proof. diff --git a/src/main/runtime/agent-session-restart-reconciliation.ts b/src/main/runtime/agent-session-restart-reconciliation.ts index 5a1c8ffdb699..b3447ec85c85 100644 --- a/src/main/runtime/agent-session-restart-reconciliation.ts +++ b/src/main/runtime/agent-session-restart-reconciliation.ts @@ -48,12 +48,7 @@ export function applyAgentSessionRestartProbes( ) { continue } - const next = applyAgentSessionRestartAdjudication({ - record, - probe: probed.probe, - now, - runtimeEnd: state.previousRuntimeEnd ?? null - }) + const next = applyAgentSessionRestartAdjudication({ record, probe: probed.probe, now }) state.records.set(sessionId, next) reconciled.set(sessionId, next) } diff --git a/src/main/runtime/agent-session-runtime-attribution.test.ts b/src/main/runtime/agent-session-runtime-attribution.test.ts new file mode 100644 index 000000000000..b05be4df724c --- /dev/null +++ b/src/main/runtime/agent-session-runtime-attribution.test.ts @@ -0,0 +1,127 @@ +// Whether a chat's agent died with the Orca runtime that held it, and how that runtime ended. Only +// an owner tagged with an earlier runtime of this build can be attributed; anything unknown keeps +// the death unattributed, so the chat keeps its generic words. + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionLease } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' +import { + agentSessionRuntimeIncarnation, + beginAgentSessionRuntimeIncarnationForTest +} from './agent-session-runtime-attribution' +import { recordAgentSessionRuntimeEnd } from './agent-session-runtime-end-record' + +const SESSION = 'session-alpha-1' +const OWNER = { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-owner' +} + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-runtime-attribution-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +/** A lease the runtime before this one held when it went away, as that runtime left it. */ +async function seedHeldBy(runtime: string | null, lease: Partial = {}) { + await seedTestAgentSessionRecordStore(directory, { + records: [ + agentSessionRecordFixture( + agentSessionLeaseFixture({ + ownerProcess: runtime ? { ...OWNER, runtime } : OWNER, + unreconciled: true, + ...lease + }) + ) + ] + }) +} + +async function restartEvidence() { + const store = await openTestAgentSessionRecordStore(directory) + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) + return store.getRecord(SESSION)?.lease.deathEvidence ?? null +} + +describe("an agent that died with Orca's previous runtime", () => { + it('died in a crash when that runtime began no quit', async () => { + await seedHeldBy('runtime-a') + expect(await restartEvidence()).toMatchObject({ kind: 'pid-absent', runtimeEnd: 'crash' }) + }) + + it.each(['update', 'quit'] as const)( + 'died with the %s that runtime began, when the quit did not finish', + async (trigger) => { + await seedHeldBy('runtime-a') + await recordAgentSessionRuntimeEnd(directory, 'runtime-a', trigger, 50_000) + expect(await restartEvidence()).toMatchObject({ runtimeEnd: trigger }) + } + ) + + it("is never told by another runtime's quit", async () => { + await seedHeldBy('runtime-b') + await recordAgentSessionRuntimeEnd(directory, 'runtime-a', 'update', 50_000) + expect(await restartEvidence()).toMatchObject({ runtimeEnd: 'crash' }) + }) + + it('names no cause when the quit records cannot be read', async () => { + await seedHeldBy('runtime-a') + await writeFile(join(directory, 'agent-session-runtime-ends.json'), '{not json') + expect(await restartEvidence()).not.toHaveProperty('runtimeEnd') + }) + + it('names no cause for an owner an older build recorded', async () => { + await seedHeldBy(null) + expect(await restartEvidence()).not.toHaveProperty('runtimeEnd') + }) + + it("names no cause for a terminal's agent", async () => { + await seedHeldBy('runtime-a', { claimStatus: 'conflicted' }) + expect(await restartEvidence()).not.toHaveProperty('runtimeEnd') + }) + + it('is told once a survivor that outlived the runtime is stopped and proven gone', async () => { + await seedHeldBy('runtime-a') + const store = await openTestAgentSessionRecordStore(directory) + // The owner was still alive at restart, so it waits in recovery for its stop. + await store.reconcileOnRestart({ + probe: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), + now: 90_000 + }) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + + await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: 7, + probe: { outcome: 'pid-absent' }, + now: 95_000 + }) + + expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ runtimeEnd: 'crash' }) + }) +}) + +describe('an agent this runtime ran', () => { + it("becomes an earlier runtime's after a relaunch", async () => { + await seedHeldBy(agentSessionRuntimeIncarnation()) + beginAgentSessionRuntimeIncarnationForTest() + expect(await restartEvidence()).toMatchObject({ runtimeEnd: 'crash' }) + }) +}) diff --git a/src/main/runtime/agent-session-runtime-attribution.ts b/src/main/runtime/agent-session-runtime-attribution.ts new file mode 100644 index 000000000000..efaf072aee17 --- /dev/null +++ b/src/main/runtime/agent-session-runtime-attribution.ts @@ -0,0 +1,67 @@ +// Which Orca runtime held each chat's agent, and, when that agent is later proven dead, whether it +// died with that runtime and how the runtime ended. Applied to every store transaction, so a death +// any transition proves (a restart's adjudication, recovery's stop of a survivor) is told the same +// way. When anything is unknown (an owner an older build recorded, a terminal's claim, unreadable +// quit records) the death names no cause and the chat keeps its generic words. + +import { randomUUID } from 'node:crypto' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +/** This Orca runtime: one per process, so a relaunch is a new one. */ +let incarnation = randomUUID() + +export function agentSessionRuntimeIncarnation(): string { + return incarnation +} + +/** For tests that relaunch Orca inside one process. */ +export function beginAgentSessionRuntimeIncarnationForTest(): void { + incarnation = randomUUID() +} + +function attributed( + before: AgentSessionRecord | undefined, + after: AgentSessionRecord, + runtimeEnds: AgentSessionStoreState['runtimeEnds'] +): AgentSessionRecord { + const { ownerProcess, deathEvidence } = after.lease + // An owner this transaction recorded is this runtime's. + if (ownerProcess && !ownerProcess.runtime && ownerProcess !== before?.lease.ownerProcess) { + return { + ...after, + lease: { ...after.lease, ownerProcess: { ...ownerProcess, runtime: incarnation } } + } + } + const heldBy = before?.lease.ownerProcess?.runtime + if ( + !deathEvidence || + deathEvidence === before?.lease.deathEvidence || + deathEvidence.runtimeEnd !== undefined || + !heldBy || + heldBy === incarnation || + // A terminal's agent, which an older build recorded: its transport was never Orca's. + before?.lease.claimStatus === 'conflicted' || + !runtimeEnds + ) { + return after + } + const runtimeEnd = runtimeEnds.get(heldBy) ?? 'crash' + return { ...after, lease: { ...after.lease, deathEvidence: { ...deathEvidence, runtimeEnd } } } +} + +/** Stamps what this transaction learned about runtimes onto the records it changed. */ +export function attributeAgentSessionRuntime( + published: AgentSessionStoreState, + draft: AgentSessionStoreState +): void { + for (const [sessionId, record] of draft.records) { + const before = published.records.get(sessionId) + if (record !== before) { + const next = attributed(before, record, draft.runtimeEnds) + if (next !== record) { + draft.records.set(sessionId, next) + } + } + } +} diff --git a/src/main/runtime/agent-session-runtime-end-record.test.ts b/src/main/runtime/agent-session-runtime-end-record.test.ts new file mode 100644 index 000000000000..8f15fc31df9c --- /dev/null +++ b/src/main/runtime/agent-session-runtime-end-record.test.ts @@ -0,0 +1,86 @@ +// The word a quitting runtime leaves: written before anything the quit waits on, and never able to +// hold the quit, however slow or broken the disk. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as NodeFsPromises from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const fsMocks = vi.hoisted(() => ({ hangWrites: false })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFile: (...args: Parameters) => + fsMocks.hangWrites ? new Promise(() => {}) : actual.writeFile(...args) + } +}) + +import { + readAgentSessionRuntimeEnds, + recordAgentSessionRuntimeEnd +} from './agent-session-runtime-end-record' +import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown' +import { agentSessionRuntimeIncarnation } from './agent-session-runtime-attribution' + +let directory: string + +beforeEach(async () => { + fsMocks.hangWrites = false + directory = await mkdtemp(join(tmpdir(), 'orca-runtime-end-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('the word a quitting runtime leaves', () => { + it('is kept per runtime, newest last', async () => { + await recordAgentSessionRuntimeEnd(directory, 'runtime-a', 'quit', 1) + await recordAgentSessionRuntimeEnd(directory, 'runtime-b', 'update', 2) + expect(readAgentSessionRuntimeEnds(directory)).toEqual( + new Map([ + ['runtime-a', 'quit'], + ['runtime-b', 'update'] + ]) + ) + }) + + it('is empty before any runtime quit here', () => { + expect(readAgentSessionRuntimeEnds(directory)).toEqual(new Map()) + }) + + it('never fails the quit when it cannot be written', async () => { + await expect( + recordAgentSessionRuntimeEnd(join(directory, 'missing'), 'runtime-a', 'quit', 1) + ).resolves.toBeUndefined() + }) + + it('never holds the quit on a disk that does not answer', async () => { + fsMocks.hangWrites = true + await expect( + recordAgentSessionRuntimeEnd(directory, 'runtime-a', 'quit', 1, 20) + ).resolves.toBeUndefined() + }) + + it('is written before the quit waits on anything, so a quit that never finishes is still a quit', async () => { + const installed = { + host: { flushAllStreamedEvents: vi.fn() }, + adapter: { closeAll: vi.fn() }, + journalDatabase: { stateDirectory: directory, close: vi.fn() }, + // A recovery that never drains: the quit's deadline ends the process here. + waitForRecovery: () => new Promise(() => {}) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown reads only the members stubbed above before it parks on the recovery wait. + void tearDownRuntime(installed as unknown as InstalledRuntime, 'update') + + await vi.waitFor(() => + expect(readAgentSessionRuntimeEnds(directory)?.get(agentSessionRuntimeIncarnation())).toBe( + 'update' + ) + ) + expect(installed.host.flushAllStreamedEvents).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/agent-session-runtime-end-record.ts b/src/main/runtime/agent-session-runtime-end-record.ts new file mode 100644 index 000000000000..5429bbb7ca2e --- /dev/null +++ b/src/main/runtime/agent-session-runtime-end-record.ts @@ -0,0 +1,89 @@ +// The durable word each Orca runtime leaves as it begins quitting: which runtime, and why (a quit +// or an update). A later start reads it to tell an owner that died with a quit that did not finish +// from one that died in a crash. Keyed by runtime, never deleted, and capped, so no later run can +// read an earlier run's word as its own. +// +// Written apart from the chat database on purpose: that database is synchronous SQLite, and a +// stalled disk there would park the quit's event loop. This write is asynchronous and bounded, and +// its failure only costs that distinction. + +import { readFileSync } from 'node:fs' +import { readFile, rename, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + AGENT_SESSION_RESUME_TRIGGERS, + type AgentSessionResumeTrigger +} from '../../shared/agent-session-resume-marker' +import { withTimeout } from '../../shared/promise-timeout-fallback' + +const RUNTIME_ENDS_FILE = 'agent-session-runtime-ends.json' +/** Enough runtimes that an owner a few restarts old still finds its runtime's word. */ +const MAX_RUNTIME_ENDS = 16 +export const RUNTIME_END_RECORD_TIMEOUT_MS = 1_000 + +type RuntimeEnd = { runtime: string; trigger: AgentSessionResumeTrigger; at: number } + +function readTrigger(value: unknown): AgentSessionResumeTrigger | undefined { + return AGENT_SESSION_RESUME_TRIGGERS.find((trigger) => trigger === value) +} + +function runtimeEndsOf(raw: string): RuntimeEnd[] { + const parsed: unknown = JSON.parse(raw) + const ends = + typeof parsed === 'object' && parsed !== null && 'ends' in parsed ? parsed.ends : null + if (!Array.isArray(ends)) { + throw new Error('agent session runtime ends are unreadable') + } + return ends.flatMap((end: unknown) => { + if (typeof end !== 'object' || end === null) { + return [] + } + const runtime = 'runtime' in end ? end.runtime : undefined + const trigger = readTrigger('trigger' in end ? end.trigger : undefined) + const at = 'at' in end ? end.at : undefined + return typeof runtime === 'string' && trigger && typeof at === 'number' + ? [{ runtime, trigger, at }] + : [] + }) +} + +/** + * Each recorded runtime's quit trigger. Empty when no runtime ever began a quit here; null when the + * word cannot be read, so nothing is ever called a crash on a failed read. + */ +export function readAgentSessionRuntimeEnds( + stateDirectory: string +): ReadonlyMap | null { + try { + const ends = runtimeEndsOf(readFileSync(join(stateDirectory, RUNTIME_ENDS_FILE), 'utf8')) + return new Map(ends.map((end) => [end.runtime, end.trigger])) + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' ? new Map() : null + } +} + +async function appendRuntimeEnd(stateDirectory: string, end: RuntimeEnd): Promise { + const path = join(stateDirectory, RUNTIME_ENDS_FILE) + const earlier = await readFile(path, 'utf8').then(runtimeEndsOf, () => []) + const ends = [...earlier.filter((entry) => entry.runtime !== end.runtime), end].slice( + -MAX_RUNTIME_ENDS + ) + const temporary = `${path}.${process.pid}.tmp` + await writeFile(temporary, JSON.stringify({ ends })) + await rename(temporary, path) +} + +/** Resolves once the word landed, failed, or ran out of time; never rejects. */ +export function recordAgentSessionRuntimeEnd( + stateDirectory: string, + runtime: string, + trigger: AgentSessionResumeTrigger, + now: number, + timeoutMs = RUNTIME_END_RECORD_TIMEOUT_MS +): Promise { + return withTimeout( + appendRuntimeEnd(stateDirectory, { runtime, trigger, at: now }).catch(() => undefined), + timeoutMs, + undefined + ) +} diff --git a/src/main/runtime/agent-session-runtime-teardown-record.test.ts b/src/main/runtime/agent-session-runtime-teardown-record.test.ts deleted file mode 100644 index 8cb494a1d697..000000000000 --- a/src/main/runtime/agent-session-runtime-teardown-record.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Whether a chat's owner died with a crashed Orca or with an Orca that was quitting: only restart -// adjudication can say the owner died with the runtime before this one, and the word that runtime -// left at the start of its quit says how it ended. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionDeathEvidence } from '../../shared/agent-session-record' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../shared/agent-session-record.test-fixture' -import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' -import { - openTestAgentSessionRecordStore, - seedTestAgentSessionRecordStore -} from './agent-session-record-store-test-harness' -import { - recordAgentSessionRuntimeTeardown, - takeAgentSessionRuntimeTeardown -} from './agent-session-runtime-teardown-record' - -const SESSION = 'session-alpha-1' - -let directory: string - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-runtime-teardown-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -/** A lease the runtime before this one still held: its owner is what restart adjudicates. */ -async function seedOwnedLease(): Promise { - await seedTestAgentSessionRecordStore(directory, { - records: [agentSessionRecordFixture(agentSessionLeaseFixture())] - }) -} - -async function seedReleasedLease(deathEvidence: AgentSessionDeathEvidence): Promise { - await seedTestAgentSessionRecordStore(directory, { - records: [ - agentSessionRecordFixture( - agentSessionLeaseFixture({ - ownerProcess: null, - reservedSpawnToken: null, - claimStatus: 'released', - deathEvidence - }) - ) - ] - }) -} - -async function restartEvidence(): Promise { - const store = await openTestAgentSessionRecordStore(directory) - await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) - return store.getRecord(SESSION)?.lease.deathEvidence ?? null -} - -describe('how the runtime that held a dead owner ended', () => { - it('is a crash when that runtime never began a quit', async () => { - await seedOwnedLease() - expect(await restartEvidence()).toMatchObject({ kind: 'pid-absent', runtimeEnd: 'crash' }) - }) - - it.each(['update', 'quit'] as const)( - 'is the %s that runtime began, when its quit did not finish', - async (trigger) => { - await seedOwnedLease() - recordAgentSessionRuntimeTeardown(openTestJournalHostDatabase(directory).db, trigger, 50_000) - - expect(await restartEvidence()).toMatchObject({ kind: 'pid-absent', runtimeEnd: trigger }) - } - ) - - it('describes only the runtime just before: the next load takes it', () => { - const db = openTestJournalHostDatabase(directory).db - recordAgentSessionRuntimeTeardown(db, 'update', 50_000) - - expect(takeAgentSessionRuntimeTeardown(db)).toBe('update') - expect(takeAgentSessionRuntimeTeardown(db)).toBe('crash') - }) - - it('never calls a quit it cannot read a crash', () => { - const db = openTestJournalHostDatabase(directory).db - db.prepare('INSERT INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( - 'runtime_teardown', - JSON.stringify({ trigger: 'logout', startedAt: 1 }) - ) - expect(takeAgentSessionRuntimeTeardown(db)).toBeNull() - }) - - it("leaves a provider's own death, observed while Orca ran, without a runtime end", async () => { - const observed: AgentSessionDeathEvidence = { - kind: 'exit-observed', - detail: 'observed process exit', - observedAt: 40_000, - ownerFence: 7 - } - await seedReleasedLease(observed) - expect(await restartEvidence()).toEqual(observed) - }) -}) diff --git a/src/main/runtime/agent-session-runtime-teardown-record.ts b/src/main/runtime/agent-session-runtime-teardown-record.ts deleted file mode 100644 index bd2624ef8e33..000000000000 --- a/src/main/runtime/agent-session-runtime-teardown-record.ts +++ /dev/null @@ -1,49 +0,0 @@ -// The durable word that a runtime began quitting, so the next start can tell an Orca that crashed -// from one whose quit did not finish. Written first thing in teardown and taken at the next load: -// any lease still owned then was granted by the runtime that ran just before, after its own load -// had taken the previous word, so a word found at load is always that runtime's. - -import { - isAgentSessionOrcaStopCause, - type AgentSessionOrcaStopCause -} from '../../shared/agent-session-failure' -import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' -import type Database from '../sqlite/sync-database' - -const RUNTIME_TEARDOWN_KEY = 'runtime_teardown' - -export function recordAgentSessionRuntimeTeardown( - db: Database.Database, - trigger: AgentSessionResumeTrigger, - now: number -): void { - db.prepare('INSERT OR REPLACE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( - RUNTIME_TEARDOWN_KEY, - JSON.stringify({ trigger, startedAt: now }) - ) -} - -/** - * How the previous runtime ended, and forgets it: its quit's trigger, or 'crash' when it began none. - * Null when that cannot be told — the read failed, or the word names a trigger this build does not - * know — so nothing is ever called a crash on a failed read. - */ -export function takeAgentSessionRuntimeTeardown( - db: Database.Database -): AgentSessionOrcaStopCause | null { - try { - const row = db - .prepare('SELECT value FROM agent_session_store_meta WHERE key = ?') - .get(RUNTIME_TEARDOWN_KEY) - if (!row) { - return 'crash' - } - db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(RUNTIME_TEARDOWN_KEY) - const value: unknown = typeof row.value === 'string' ? JSON.parse(row.value) : null - const trigger = - typeof value === 'object' && value !== null && 'trigger' in value ? value.trigger : null - return trigger !== 'crash' && isAgentSessionOrcaStopCause(trigger) ? trigger : null - } catch { - return null - } -} diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 31bddace8731..4a4b03c0f448 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -13,6 +13,8 @@ import { journalOpenRefusalError } from '../native-chat/agent-session-journal/jo import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' import type { AgentSessionStoreState } from './agent-session-record-store-file' import { writeAgentSessionStoreRows } from './agent-session-record-rows' +import { attributeAgentSessionRuntime } from './agent-session-runtime-attribution' +import { readAgentSessionRuntimeEnds } from './agent-session-runtime-end-record' import { agentSessionStoreDraftRowWrites, draftAgentSessionStoreState, @@ -79,7 +81,10 @@ export class AgentSessionStoreTransactions { loaded: AgentSessionStoreState ) { freezeRows(loaded, null) - this.published = loaded + this.published = { + ...loaded, + runtimeEnds: readAgentSessionRuntimeEnds(journalDatabase.stateDirectory) + } } /** The committed state. A transaction in flight never shows here until its rows have landed. */ @@ -148,6 +153,7 @@ export class AgentSessionStoreTransactions { const published = this.published const draft = draftAgentSessionStoreState(published) const result = apply(draft) + attributeAgentSessionRuntime(published, draft) const writes = agentSessionStoreDraftRowWrites(published, draft) return { result, diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 47d93e028307..896515a5d06d 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -7,6 +7,8 @@ import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { agentSessionRuntimeIncarnation } from './agent-session-runtime-attribution' +import { recordAgentSessionRuntimeEnd } from './agent-session-runtime-end-record' export type InstalledRuntime = { host: StructuredAgentSessionHost @@ -35,9 +37,18 @@ export async function tearDownRuntime( installed: InstalledRuntime, trigger: AgentSessionResumeTrigger ): Promise { + // First, before any wait: should this quit not finish, the next start must still know it was a + // quit and not a crash. Bounded and never rejects. + const runtimeEndRecorded = recordAgentSessionRuntimeEnd( + installed.journalDatabase.stateDirectory, + agentSessionRuntimeIncarnation(), + trigger, + Date.now() + ) // Drain an in-flight recovery before stopping children; recovery may still // be writing lifecycle rows or acquiring a replacement child. await installed.waitForRecovery() + await runtimeEndRecorded const failures: unknown[] = [] // Host teardown runs FIRST, which inverts the older order. It is what stops this host's // provider children now: it evicts each owned session through the adapter, and that eviction diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 4ed542850373..215866ea5e4e 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -3,10 +3,10 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ - call: vi.fn(), - supported: true -})) +const mocks = vi.hoisted(() => { + const hostLabel = (): string | null => 'studio-mac' + return { call: vi.fn(), supported: true, hostLabel: hostLabel() } +}) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -15,12 +15,21 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('@/runtime/structured-agent-session-host-capability', () => ({ useStructuredAgentSessionHostCapability: () => mocks.supported })) +vi.mock('./use-structured-agent-session-host-label', () => ({ + useStructuredAgentSessionHostLabel: () => mocks.hostLabel +})) -import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { TooltipProvider } from '@/components/ui/tooltip' import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' -import { NativeChatInterruptedContinue } from './NativeChatInterruptedContinue' +import { + NativeChatInterruptedContinue, + useNativeChatInterruptedContinuation +} from './NativeChatInterruptedContinue' const TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 }) const PAIRED: RuntimeClientTarget = { kind: 'environment', environmentId: 'studio-mac' } @@ -39,85 +48,99 @@ const cutChat: AgentJournalRenderItem[] = [ sequence: 2, observedAt: 6, turnScope: { kind: 'turn', turnItemId: TURN }, - body: { - kind: 'status', - text: 'Codex stopped.', - tone: 'error', - failure: agentSessionFailureFact('providerExited', { orcaStop: { cause: 'crash' } }) - } + body: { kind: 'status', text: 'Codex stopped.', tone: 'error', orcaStop: { cause: 'crash' } } } ] -function renderContinue( - overrides: Partial[0]> = {} -) { - const onError = vi.fn() - render( - +type Props = { + journalItems?: readonly AgentJournalRenderItem[] + submissions?: readonly Pick[] + isWorking?: boolean + onError?: (message: string | null) => void +} + +function Harness(props: Props): React.JSX.Element { + const continuation = useNativeChatInterruptedContinuation({ + target: PAIRED, + sessionId: 'session-1', + journalItems: props.journalItems ?? cutChat, + submissions: props.submissions ?? [], + isWorking: props.isWorking ?? false, + onError: props.onError ?? vi.fn() + }) + return ( + + {continuation.view.continueTurnItemId ?? 'none'} + + ) - return { onError } } +const continueButton = () => screen.queryByRole('button', { name: 'Continue' }) + beforeEach(() => { mocks.supported = true + mocks.hostLabel = 'studio-mac' mocks.call.mockReset() }) afterEach(cleanup) describe('Continue on a reply an Orca stop cut off', () => { - it("asks the chat's own host, paired or local, to continue that cut turn", async () => { + it("asks the chat's own host, paired or local, to continue that cut turn", () => { mocks.call.mockResolvedValue({ outcome: 'pending' }) - renderContinue() + render() + expect(screen.getByTestId('offered')).toHaveTextContent(TURN) - fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.click(continueButton()!) expect(mocks.call).toHaveBeenCalledExactlyOnceWith(PAIRED, 'agentSession.continueInterrupted', { sessionId: 'session-1', turnItemId: TURN }) - // Gone once asked: the journal shows what came of it. - expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + // Gone once asked, and the row gets its own way on back: the journal shows what came of it. + expect(continueButton()).toBeNull() + expect(screen.getByTestId('offered')).toHaveTextContent('none') + }) + + it('says what it does, for a reader and on hover', () => { + render() + expect(continueButton()).toHaveAccessibleDescription( + 'Continue, and the agent first checks whether its last step finished.' + ) }) - it('is not offered by a host without the operation', () => { + it('is not offered by a host without the operation; the user continues by sending', () => { mocks.supported = false - renderContinue() - expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + render() + expect(continueButton()).toBeNull() }) it('is not offered once anything was sent, or while the agent works', () => { - renderContinue({ submissions: [{ dispatchState: 'pending' }] }) - expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + render() + expect(continueButton()).toBeNull() cleanup() - renderContinue({ isWorking: true }) - expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + render() + expect(continueButton()).toBeNull() }) it('is not offered for a cut no Orca stop explains', () => { - renderContinue({ journalItems: cutChat.slice(0, 1) }) - expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull() + render() + expect(continueButton()).toBeNull() }) it('says so and offers it again when the request fails', async () => { mocks.call.mockRejectedValue(new Error('offline')) - const { onError } = renderContinue() + const onError = vi.fn() + render() - fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.click(continueButton()!) await waitFor(() => expect(onError).toHaveBeenLastCalledWith( "Couldn't continue this chat. Try again, or send a message." ) ) - expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument() + expect(continueButton()).toBeInTheDocument() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index f1968c759009..b41607a9416f 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react' +import { useId, useMemo, useState } from 'react' import { Play } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { useStructuredAgentSessionHostCapability } from '@/runtime/structured-agent-session-host-capability' @@ -11,58 +12,60 @@ import type { } from '../../../../shared/agent-session-journal-types' import { latestNativeChatOrcaStopCut } from '../../../../shared/native-chat-orca-stop-cut' import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { NativeChatOrcaStopView } from './native-chat-orca-stop-context' +import { useStructuredAgentSessionHostLabel } from './use-structured-agent-session-host-label' type ContinueAnswer = { outcome?: string } +export type NativeChatInterruptedContinuation = { + /** What the chat's rows are told: the machine's name and the cut Continue is offered on. */ + view: NativeChatOrcaStopView + continueNow: () => void +} + /** * Continue, while the chat's latest turn is a reply an Orca stop cut off and nothing was sent since. - * Shown only for a host that has the operation; an older host writes no row naming the stop, so it - * never has a cut to show this for. The chat itself reports what the continuation did. + * Offered only by a host that has the operation; an older host writes no row naming the stop, so it + * never has a cut to offer it on, and the user continues by sending a message. */ -export function NativeChatInterruptedContinue({ - target, - sessionId, - journalItems, - submissions, - isWorking, - onError -}: { +export function useNativeChatInterruptedContinuation(input: { target: RuntimeClientTarget sessionId: string journalItems: readonly AgentJournalRenderItem[] submissions: readonly Pick[] isWorking: boolean onError: (message: string | null) => void -}): React.JSX.Element | null { +}): NativeChatInterruptedContinuation { + const { target, sessionId, onError } = input + const hostLabel = useStructuredAgentSessionHostLabel(target) const supported = useStructuredAgentSessionHostCapability( target, AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY ) const cut = useMemo( - () => latestNativeChatOrcaStopCut(journalItems, submissions), - [journalItems, submissions] + () => latestNativeChatOrcaStopCut(input.journalItems, input.submissions), + [input.journalItems, input.submissions] ) // The cut this client already asked to continue: hidden until the journal shows what came of it. const [asked, setAsked] = useState(null) - if (!supported || !cut || isWorking || asked === cut.turnItemId) { - return null - } - const turnItemId = cut.turnItemId - const onContinue = (): void => { + const offered = + supported && cut && !input.isWorking && asked !== cut.turnItemId ? cut.turnItemId : null + const continueNow = (): void => { + if (offered === null) { + return + } + const turnItemId = offered + const retryable = (): void => setAsked((current) => (current === turnItemId ? null : current)) setAsked(turnItemId) onError(null) void callStructuredAgentSession(target, 'agentSession.continueInterrupted', { sessionId, turnItemId }).then( - (answer) => { - // Refused before anything was sent: the host's note says why, and Continue may try again. - if (answer.outcome === 'refused') { - setAsked((current) => (current === turnItemId ? null : current)) - } - }, + // Refused before anything was sent: the host's note says why, and Continue may try again. + (answer) => (answer.outcome === 'refused' ? retryable() : undefined), () => { - setAsked((current) => (current === turnItemId ? null : current)) + retryable() onError( translate( 'components.native-chat.interruptedContinue.failed', @@ -72,12 +75,46 @@ export function NativeChatInterruptedContinue({ } ) } + // One object per change, so the chat's rows re-render only when what they show changes. + const view = useMemo(() => ({ hostLabel, continueTurnItemId: offered }), [hostLabel, offered]) + return { view, continueNow } +} + +export function NativeChatInterruptedContinue({ + continuation +}: { + continuation: NativeChatInterruptedContinuation +}): React.JSX.Element | null { + const explanationId = useId() + if (continuation.view.continueTurnItemId === null) { + return null + } + const explanation = translate( + 'components.native-chat.interruptedContinue.explanation', + 'Continue, and the agent first checks whether its last step finished.' + ) return (
- + + + + + + {explanation} + + + + {explanation} +
) } diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index cc2df1ec6dc9..be7d92b24a71 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -5,18 +5,40 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types' import { MessageRow } from './NativeChatMessageRow' -import { NativeChatHostLabelContext } from './native-chat-host-label-context' +import { + NativeChatOrcaStopContext, + type NativeChatOrcaStopView +} from './native-chat-orca-stop-context' afterEach(cleanup) -function renderStatus(body: AgentJournalStatusItem, hostLabel: string | null = null) { +function orcaStopView( + hostLabel: string | null, + continueTurnItemId: string | null +): NativeChatOrcaStopView { + return { hostLabel, continueTurnItemId } +} + +function renderStatus( + body: AgentJournalStatusItem, + hostLabel: string | null = null, + continueTurnItemId: string | null = null +) { const [message] = projectStructuredItemsToNativeChat([ - { itemId: 'notice', sequence: 1, revision: 1, observedAt: 1, body } + { + itemId: 'notice', + sequence: 1, + revision: 1, + observedAt: 1, + body, + turnScope: { kind: 'turn', turnItemId: 'cut-turn' } + } ]) + const view = orcaStopView(hostLabel, continueTurnItemId) return render( - + - + ) } @@ -28,25 +50,31 @@ function orcaStopRow(cause: string): AgentJournalStatusItem { kind: 'status', text: LEGACY_TEXT, tone: 'error', - failure: { kind: 'providerExited', orcaStop: { cause } } + orcaStop: { cause } } } describe('the row an Orca stop leaves', () => { it.each([ - ['update', 'Stopped: Orca on studio-mac restarted for an update'], - ['quit', 'Stopped: Orca on studio-mac was restarted'], - ['crash', 'Stopped: Orca on studio-mac stopped unexpectedly'] - ])('names a %s and the machine', (cause, headline) => { + ['update', 'Orca on studio-mac restarted for an update while this response was in progress.'], + ['quit', 'Orca on studio-mac was closed while this response was in progress.'], + ['crash', 'Orca on studio-mac stopped unexpectedly while this response was in progress.'] + ])('names a %s and the machine, muted', (cause, sentence) => { renderStatus(orcaStopRow(cause), 'studio-mac') - const row = screen.getByText(new RegExp(headline)) - expect(row).toHaveTextContent( - 'The reply was cut off partway through. Continue, and the agent first checks whether its last step finished.' - ) - expect(row.parentElement?.parentElement).toHaveClass('text-destructive') + const row = screen.getByText(`${sentence} You can continue in this conversation.`) + expect(row.parentElement?.parentElement).toHaveClass('text-muted-foreground') expect(screen.queryByText(LEGACY_TEXT)).toBeNull() }) + it('leaves the way on to Continue while Continue is offered on its turn', () => { + renderStatus(orcaStopRow('update'), 'studio-mac', 'cut-turn') + expect( + screen.getByText( + 'Orca on studio-mac restarted for an update while this response was in progress.' + ) + ).toBeInTheDocument() + }) + 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() @@ -58,7 +86,8 @@ describe('the row an Orca stop leaves', () => { }) it('keeps the host words for a stop Orca did not cause', () => { - renderStatus({ ...orcaStopRow('update'), failure: { kind: 'providerExited' } }, 'studio-mac') + const { orcaStop: _orcaStop, ...agentExit } = orcaStopRow('update') + renderStatus(agentExit, 'studio-mac') expect(screen.getByText(LEGACY_TEXT)).toBeInTheDocument() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index 83ff93780b28..f825c0354b1a 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -10,9 +10,9 @@ import { isAgentSessionHostStatusPresentation, type AgentSessionHostStatusPresentation } from '../../../../shared/agent-session-host-status-rows' -import type { AgentSessionOrcaStopCause } from '../../../../shared/agent-session-failure' import type { NativeChatTextBlock } from '../../../../shared/native-chat-types' -import { useNativeChatHostLabel } from './native-chat-host-label-context' +import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' +import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' import { ProviderFrameRow } from './NativeChatTranscriptChrome' const HOST_STATUS_WORDS: Record string> = { @@ -28,29 +28,6 @@ const HOST_STATUS_WORDS: Record string ) } -const ORCA_STOP_HEADLINES: Record string> = { - update: (machine) => - translate( - 'components.native-chat.notices.orcaStopUpdate', - 'Stopped: Orca on {{machine}} restarted for an update', - { machine } - ), - quit: (machine) => - translate( - 'components.native-chat.notices.orcaStopQuit', - 'Stopped: Orca on {{machine}} was restarted', - { - machine - } - ), - crash: (machine) => - translate( - 'components.native-chat.notices.orcaStopCrash', - 'Stopped: Orca on {{machine}} stopped unexpectedly', - { machine } - ) -} - export function NativeChatNoticeRow({ block, onLinkClick, @@ -60,7 +37,7 @@ export function NativeChatNoticeRow({ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean }): React.JSX.Element { - const hostLabel = useNativeChatHostLabel() + const orcaStopView = useNativeChatOrcaStopView() if (block.presentation === 'compaction') { const label = translate('components.native-chat.notices.compaction', 'Context compacted') return ( @@ -112,17 +89,18 @@ export function NativeChatNoticeRow({ ) } - // The host's row about an Orca stop: named only when this chat knows its machine, else the - // generic words the host wrote. - const orcaStop = block.failure?.kind === 'providerExited' ? block.failure.orcaStop : undefined - const text = - orcaStop && hostLabel - ? `${ORCA_STOP_HEADLINES[orcaStop.cause](hostLabel)}\n${translate( - 'components.native-chat.notices.orcaStopDetail', - 'The reply was cut off partway through. Continue, and the agent first checks whether its last step finished.' - )}` - : block.text - const tone = block.tone + // The host's row about an Orca stop names the cause and the machine, muted: Orca stopped, not the + // agent. With no machine to name it keeps the host's own words. + const { orcaStop } = block + const { hostLabel, continueTurnItemId } = orcaStopView + const named = orcaStop !== undefined && hostLabel !== null + const text = named + ? nativeChatOrcaStopRowText(orcaStop.cause, hostLabel, { + continueOffered: + orcaStop.turnItemId !== undefined && orcaStop.turnItemId === continueTurnItemId + }) + : block.text + const tone = named ? 'notice' : block.tone const Icon = tone === 'warning' ? AlertTriangle diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 0f483dcbad04..f26c12185802 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -22,9 +22,11 @@ import type { NativeChatStructuredViewProps } from './native-chat-view-types' import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSessionStatus' import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' -import { NativeChatInterruptedContinue } from './NativeChatInterruptedContinue' -import { NativeChatHostLabelContext } from './native-chat-host-label-context' -import { useStructuredAgentSessionHostLabel } from './use-structured-agent-session-host-label' +import { + NativeChatInterruptedContinue, + useNativeChatInterruptedContinuation +} from './NativeChatInterruptedContinue' +import { NativeChatOrcaStopContext } from './native-chat-orca-stop-context' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSessionStatusBridge' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' @@ -116,7 +118,14 @@ export function NativeChatStructuredSession( [controller, historyPhase, props.agent, props.sessionId] ) const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') - const hostLabel = useStructuredAgentSessionHostLabel(props.target) + const continuation = useNativeChatInterruptedContinuation({ + target: props.target, + sessionId: props.sessionId, + journalItems: controller.journalItems, + submissions: controller.submissions, + isWorking: controller.isWorking, + onError: setComposerError + }) const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ outbox: controller.outbox, submissions: controller.submissions, @@ -264,7 +273,7 @@ export function NativeChatStructuredSession( ) : viewState.kind === 'empty' ? ( ) : ( - + - + )} {readFailedFinally ? null : ( @@ -295,14 +304,7 @@ export function NativeChatStructuredSession( agentLabel={agentLabel} onRetry={provisionalLaunch.retry} /> - + {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} (null) - -export function useNativeChatHostLabel(): string | null { - return useContext(NativeChatHostLabelContext) -} diff --git a/src/renderer/src/components/native-chat/native-chat-orca-stop-context.ts b/src/renderer/src/components/native-chat/native-chat-orca-stop-context.ts new file mode 100644 index 000000000000..280b7f788a16 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-orca-stop-context.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react' + +/** What a structured chat tells its rows about an Orca stop: the name of the machine whose Orca runs + * it, as Orca shows that host everywhere (null when it has none to show), and the cut turn Continue + * is offered on, if any. */ +export type NativeChatOrcaStopView = { + hostLabel: string | null + continueTurnItemId: string | null +} + +export const NativeChatOrcaStopContext = createContext({ + hostLabel: null, + continueTurnItemId: null +}) + +export function useNativeChatOrcaStopView(): NativeChatOrcaStopView { + return useContext(NativeChatOrcaStopContext) +} 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 new file mode 100644 index 000000000000..7737f2e0561d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-orca-stop-words.ts @@ -0,0 +1,45 @@ +// The words for a reply Orca's own stop cut off, by cause and machine. One function, so the chat's +// row wording can change in one place. + +import { translate } from '@/i18n/i18n' +import type { AgentSessionOrcaStopCause } from '../../../../shared/agent-session-orca-stop' +import { joinSentences } from '../../../../shared/sentence-joining' + +const STOPPED: Record string> = { + update: (machine) => + translate( + 'components.native-chat.notices.orcaStopUpdate', + 'Orca on {{machine}} restarted for an update while this response was in progress.', + { machine } + ), + quit: (machine) => + translate( + 'components.native-chat.notices.orcaStopQuit', + 'Orca on {{machine}} was closed while this response was in progress.', + { machine } + ), + crash: (machine) => + translate( + 'components.native-chat.notices.orcaStopCrash', + 'Orca on {{machine}} stopped unexpectedly while this response was in progress.', + { machine } + ) +} + +/** The row's sentence. While Continue is offered beside it, the button is the way on, so the row + * does not say it again. */ +export function nativeChatOrcaStopRowText( + cause: AgentSessionOrcaStopCause, + machine: string, + options: { continueOffered: boolean } +): string { + return options.continueOffered + ? STOPPED[cause](machine) + : joinSentences([ + STOPPED[cause](machine), + translate( + 'components.native-chat.notices.orcaStopCanContinue', + 'You can continue in this conversation.' + ) + ]) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b4f5fb2aaa8..f988526780b3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17853,10 +17853,10 @@ "plan": "Plan", "historyRepaired": "Part of this chat's history couldn't be loaded.", "historyItemTooLarge": "This part of the chat was too large to show.", - "orcaStopUpdate": "Stopped: Orca on {{machine}} restarted for an update", - "orcaStopQuit": "Stopped: Orca on {{machine}} was restarted", - "orcaStopCrash": "Stopped: Orca on {{machine}} stopped unexpectedly", - "orcaStopDetail": "The reply was cut off partway through. Continue, and the agent first checks whether its last step finished." + "orcaStopUpdate": "Orca on {{machine}} restarted for an update while this response was in progress.", + "orcaStopQuit": "Orca on {{machine}} was closed while this response was in progress.", + "orcaStopCrash": "Orca on {{machine}} stopped unexpectedly while this response was in progress.", + "orcaStopCanContinue": "You can continue in this conversation." }, "composer": { "imageUnsupported": "Image paste is not supported for this agent.", @@ -18214,7 +18214,8 @@ "structuredSessionHostUnreachableDescription": "Orca did not open a {{value0}} chat. Check the connection to the server and try again.", "interruptedContinue": { "continue": "Continue", - "failed": "Couldn't continue this chat. Try again, or send a message." + "failed": "Couldn't continue this chat. Try again, or send a message.", + "explanation": "Continue, and the agent first checks whether its last step finished." } }, "tab": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 80f030b58971..1420e41d51fe 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17933,10 +17933,10 @@ "plan": "Plan", "historyRepaired": "No se pudo cargar parte del historial de este chat.", "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla.", - "orcaStopUpdate": "Detenido: Orca en {{machine}} se reinició para actualizarse", - "orcaStopQuit": "Detenido: Orca en {{machine}} se reinició", - "orcaStopCrash": "Detenido: Orca en {{machine}} se detuvo inesperadamente", - "orcaStopDetail": "La respuesta se cortó a medias. Continúa y el agente comprobará primero si su último paso terminó." + "orcaStopUpdate": "Orca en {{machine}} se reinició para actualizarse mientras esta respuesta estaba en curso.", + "orcaStopQuit": "Orca en {{machine}} se cerró mientras esta respuesta estaba en curso.", + "orcaStopCrash": "Orca en {{machine}} se detuvo inesperadamente mientras esta respuesta estaba en curso.", + "orcaStopCanContinue": "Puedes continuar en esta conversación." }, "providerFrame": { "byteLength": "{{value0}} bytes" @@ -17986,7 +17986,8 @@ "structuredSessionHostUnreachableDescription": "Orca no abrió un chat de {{value0}}. Comprueba la conexión con el servidor y vuelve a intentarlo.", "interruptedContinue": { "continue": "Continuar", - "failed": "No se pudo continuar este chat. Inténtalo de nuevo o envía un mensaje." + "failed": "No se pudo continuar este chat. Inténtalo de nuevo o envía un mensaje.", + "explanation": "Continúa y el agente comprobará primero si su último paso terminó." } }, "tab": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index b39aaa121a05..5f3311855729 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17934,10 +17934,10 @@ "plan": "Plan", "historyRepaired": "Une partie de l'historique de ce chat n'a pas pu être chargée.", "historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée.", - "orcaStopUpdate": "Arrêté : Orca sur {{machine}} a redémarré pour une mise à jour", - "orcaStopQuit": "Arrêté : Orca sur {{machine}} a été redémarré", - "orcaStopCrash": "Arrêté : Orca sur {{machine}} s'est arrêté de manière inattendue", - "orcaStopDetail": "La réponse a été interrompue en cours de route. Continuez, et l'agent vérifiera d'abord si sa dernière étape s'est terminée." + "orcaStopUpdate": "Orca sur {{machine}} a redémarré pour une mise à jour pendant que cette réponse était en cours.", + "orcaStopQuit": "Orca sur {{machine}} a été fermé pendant que cette réponse était en cours.", + "orcaStopCrash": "Orca sur {{machine}} s'est arrêté de manière inattendue pendant que cette réponse était en cours.", + "orcaStopCanContinue": "Vous pouvez continuer dans cette conversation." }, "providerFrame": { "byteLength": "{{value0}} octets" @@ -18085,7 +18085,8 @@ "structuredSessionHostUnreachableDescription": "Orca n'a pas ouvert de discussion {{value0}}. Vérifiez la connexion au serveur et réessayez.", "interruptedContinue": { "continue": "Continuer", - "failed": "Impossible de poursuivre cette conversation. Réessayez ou envoyez un message." + "failed": "Impossible de poursuivre cette conversation. Réessayez ou envoyez un message.", + "explanation": "Continuez, et l'agent vérifiera d'abord si sa dernière étape s'est terminée." } }, "tab": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d3d35455fd82..3520871b7580 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17870,10 +17870,10 @@ "plan": "プラン", "historyRepaired": "このチャットの履歴の一部を読み込めませんでした。", "historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。", - "orcaStopUpdate": "停止: {{machine}} の Orca がアップデートのため再起動しました", - "orcaStopQuit": "停止: {{machine}} の Orca が再起動されました", - "orcaStopCrash": "停止: {{machine}} の Orca が予期せず停止しました", - "orcaStopDetail": "返信は途中で中断されました。続行すると、Agent はまず最後の手順が完了したかどうかを確認します。" + "orcaStopUpdate": "この応答の途中で、{{machine}} の Orca がアップデートのため再起動しました。", + "orcaStopQuit": "この応答の途中で、{{machine}} の Orca が終了しました。", + "orcaStopCrash": "この応答の途中で、{{machine}} の Orca が予期せず停止しました。", + "orcaStopCanContinue": "この会話で続行できます。" }, "providerFrame": { "byteLength": "{{value0}}バイト" @@ -18021,7 +18021,8 @@ "structuredSessionHostUnreachableDescription": "Orca は {{value0}} チャットを開きませんでした。サーバーへの接続を確認して、もう一度お試しください。", "interruptedContinue": { "continue": "続行", - "failed": "このチャットを続行できませんでした。もう一度試すか、メッセージを送信してください。" + "failed": "このチャットを続行できませんでした。もう一度試すか、メッセージを送信してください。", + "explanation": "続行すると、Agent はまず最後の手順が完了したかどうかを確認します。" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4de146a92872..0e6efb626579 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17870,10 +17870,10 @@ "plan": "계획", "historyRepaired": "이 채팅 기록의 일부를 불러올 수 없습니다.", "historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다.", - "orcaStopUpdate": "중지됨: {{machine}}의 Orca가 업데이트를 위해 다시 시작됨", - "orcaStopQuit": "중지됨: {{machine}}의 Orca가 다시 시작됨", - "orcaStopCrash": "중지됨: {{machine}}의 Orca가 예기치 않게 중지됨", - "orcaStopDetail": "응답이 중간에 끊겼습니다. 계속하면 에이전트가 먼저 마지막 단계가 완료되었는지 확인합니다." + "orcaStopUpdate": "이 응답이 진행되는 동안 {{machine}}의 Orca가 업데이트를 위해 다시 시작되었습니다.", + "orcaStopQuit": "이 응답이 진행되는 동안 {{machine}}의 Orca가 종료되었습니다.", + "orcaStopCrash": "이 응답이 진행되는 동안 {{machine}}의 Orca가 예기치 않게 중지되었습니다.", + "orcaStopCanContinue": "이 대화에서 계속할 수 있습니다." }, "providerFrame": { "byteLength": "{{value0}}바이트" @@ -18021,7 +18021,8 @@ "structuredSessionHostUnreachableDescription": "Orca가 {{value0}} 채팅을 열지 않았습니다. 서버 연결을 확인하고 다시 시도하세요.", "interruptedContinue": { "continue": "계속", - "failed": "이 채팅을 계속할 수 없습니다. 다시 시도하거나 메시지를 보내세요." + "failed": "이 채팅을 계속할 수 없습니다. 다시 시도하거나 메시지를 보내세요.", + "explanation": "계속하면 에이전트가 먼저 마지막 단계가 완료되었는지 확인합니다." } }, "tab": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2c5f329cc071..7e01b4e081cb 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17835,10 +17835,10 @@ "plan": "计划", "historyRepaired": "此聊天的部分历史记录无法加载。", "historyItemTooLarge": "聊天的这一部分过大,无法显示。", - "orcaStopUpdate": "已停止:{{machine}} 上的 Orca 为更新而重新启动", - "orcaStopQuit": "已停止:{{machine}} 上的 Orca 已重新启动", - "orcaStopCrash": "已停止:{{machine}} 上的 Orca 意外停止", - "orcaStopDetail": "回复在中途被截断。继续后,代理会先检查其上一步是否已完成。" + "orcaStopUpdate": "此回复进行期间,{{machine}} 上的 Orca 为更新而重新启动。", + "orcaStopQuit": "此回复进行期间,{{machine}} 上的 Orca 已关闭。", + "orcaStopCrash": "此回复进行期间,{{machine}} 上的 Orca 意外停止。", + "orcaStopCanContinue": "你可以在此对话中继续。" }, "providerFrame": { "byteLength": "{{value0}} 字节" @@ -17986,7 +17986,8 @@ "structuredSessionHostUnreachableDescription": "Orca 未打开 {{value0}} 聊天。请检查与服务器的连接,然后重试。", "interruptedContinue": { "continue": "继续", - "failed": "无法继续此聊天。请重试,或发送一条消息。" + "failed": "无法继续此聊天。请重试,或发送一条消息。", + "explanation": "继续后,代理会先检查其上一步是否已完成。" } }, "tab": { diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 9aa83279a68d..b08c5250a150 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -134,24 +134,6 @@ export type AgentSessionFailureFact = { attachment?: AgentSessionAttachmentProblem /** On `providerRetrying`: why the provider is retrying. */ retry?: AgentSessionProviderRetry - /** On `providerExited`: Orca itself stopped on the chat's host, and why. */ - orcaStop?: AgentSessionOrcaStop -} - -/** Why the Orca running a chat stopped mid-reply. Open on the wire: a cause this build does not know - * reads as no cause, so the row keeps its generic words. */ -export const AGENT_SESSION_ORCA_STOP_CAUSES = ['update', 'quit', 'crash'] as const -export type AgentSessionOrcaStopCause = (typeof AGENT_SESSION_ORCA_STOP_CAUSES)[number] -export type AgentSessionOrcaStop = { cause: AgentSessionOrcaStopCause } - -export function isAgentSessionOrcaStopCause(value: unknown): value is AgentSessionOrcaStopCause { - return AGENT_SESSION_ORCA_STOP_CAUSES.some((cause) => cause === value) -} - -function readOrcaStop(value: unknown): AgentSessionOrcaStop | undefined { - return isRecord(value) && isAgentSessionOrcaStopCause(value.cause) - ? { cause: value.cause } - : undefined } /** A fact as a row stores it: its kind may be one a newer host added, so only @@ -183,7 +165,6 @@ export function agentSessionFailureFact( refusal?: AgentSessionRefusalReference attachment?: AgentSessionAttachmentProblem retry?: AgentSessionProviderRetry - orcaStop?: AgentSessionOrcaStop } = {} ): AgentSessionFailureFact & { kind: TKind } { // Re-bounded here, so no writer can store more than the cap however it built the detail. @@ -195,8 +176,7 @@ export function agentSessionFailureFact( ...(detail ? { detail } : {}), ...(extra.refusal ? { refusal: extra.refusal } : {}), ...(extra.attachment ? { attachment: extra.attachment } : {}), - ...(extra.retry ? { retry: extra.retry } : {}), - ...(extra.orcaStop ? { orcaStop: { cause: extra.orcaStop.cause } } : {}) + ...(extra.retry ? { retry: extra.retry } : {}) } } @@ -253,13 +233,11 @@ export function readAgentSessionFailureFact(value: unknown): AgentSessionFailure const refusal = readAgentSessionRefusalReference(value.refusal) const attachment = readAttachmentProblem(value.attachment) const retry = readProviderRetry(value.retry) - const orcaStop = readOrcaStop(value.orcaStop) return agentSessionFailureFact(value.kind, { ...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}), ...(refusal ? { refusal } : {}), ...(attachment ? { attachment } : {}), - ...(retry ? { retry } : {}), - ...(orcaStop ? { orcaStop } : {}) + ...(retry ? { retry } : {}) }) } diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index cb5e0e573d7d..124c2f1a3f64 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -262,7 +262,8 @@ const KnownItemBody = z.discriminatedUnion('kind', [ .optional(), providerFrame: ProviderFrame.optional(), threadGoal: ThreadGoalState.optional(), - failure: FailureFact.optional() + failure: FailureFact.optional(), + orcaStop: z.object({ cause: z.string().min(1) }).optional() }), z.object({ kind: z.literal('turn'), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 9613afa8a849..2e4790abb6b6 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -297,6 +297,9 @@ type AgentJournalStatusItemFields = { } /** Present on thread-goal transitions; absent on rows from older hosts. */ threadGoal?: AgentJournalThreadGoalState + /** On the row about a reply Orca's own stop cut off: why Orca stopped. Its cause is open (a newer + * host may name one this build does not know), so read it with `readAgentSessionOrcaStop`. */ + orcaStop?: { cause: string } } /** A status row that reports no failure; its text is its writer's own. */ diff --git a/src/shared/agent-session-orca-stop.ts b/src/shared/agent-session-orca-stop.ts new file mode 100644 index 000000000000..32abca4949d6 --- /dev/null +++ b/src/shared/agent-session-orca-stop.ts @@ -0,0 +1,18 @@ +// 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. + +export const AGENT_SESSION_ORCA_STOP_CAUSES = ['update', 'quit', 'crash'] as const +export type AgentSessionOrcaStopCause = (typeof AGENT_SESSION_ORCA_STOP_CAUSES)[number] +export type AgentSessionOrcaStop = { cause: AgentSessionOrcaStopCause } + +export function isAgentSessionOrcaStopCause(value: unknown): value is AgentSessionOrcaStopCause { + return AGENT_SESSION_ORCA_STOP_CAUSES.some((cause) => cause === value) +} + +/** The stop a row states, when this build knows its cause. */ +export function readAgentSessionOrcaStop(value: unknown): AgentSessionOrcaStop | undefined { + const cause = + typeof value === 'object' && value !== null && 'cause' in value ? value.cause : undefined + return isAgentSessionOrcaStopCause(cause) ? { cause } : undefined +} diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index ffd28d3f7bc6..d08291681a5d 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -72,6 +72,9 @@ export type AgentSessionProcessIdentity = { pid: number processStartTimeMs: number | null spawnToken: string + /** The Orca runtime that started this process and holds its transport, stamped when the owner is + * recorded; absent on owners older builds recorded. */ + runtime?: string } export type AgentSessionJournalCheckpoint = { epoch: number; sequence: number } @@ -94,10 +97,10 @@ export type AgentSessionDeathEvidence = { * proved it alive. Only a probe's proof records it: absent on a surface-release exit, a failed * start, and evidence older builds wrote. */ lastProvenAliveAt?: number - /** Only from restart adjudication, which proves the owner died with the Orca runtime that held - * it: how that runtime ended ('quit' | 'update' when it had begun a quit, else 'crash'). Absent - * for a provider that died on its own. A string, since a newer build may write a cause this one - * does not know; read it with `isAgentSessionOrcaStopCause`. */ + /** How the Orca runtime that held the owner ended, when the owner died with it: the quit or + * update it had begun, else 'crash'. Absent when a provider died on its own while Orca ran, and + * whenever that cannot be told. A string, since a newer build may write a cause this one does + * not know; read it with `isAgentSessionOrcaStopCause`. */ runtimeEnd?: string } @@ -227,7 +230,8 @@ export function isAgentSessionProcessIdentity( (identity.processStartTimeMs === null || (Number.isSafeInteger(identity.processStartTimeMs) && (identity.processStartTimeMs as number) >= 0)) && - isBoundedString(identity.spawnToken, MAX_ID_LENGTH) + isBoundedString(identity.spawnToken, MAX_ID_LENGTH) && + (identity.runtime === undefined || isBoundedString(identity.runtime, MAX_ID_LENGTH)) ) } diff --git a/src/shared/native-chat-orca-stop-cut.test.ts b/src/shared/native-chat-orca-stop-cut.test.ts index b9e3c0549447..842545974726 100644 --- a/src/shared/native-chat-orca-stop-cut.test.ts +++ b/src/shared/native-chat-orca-stop-cut.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { agentSessionFailureFact, readAgentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureFact } from './agent-session-failure' import { agentSessionFailureWords } from './agent-session-failure-words' +import { readAgentSessionOrcaStop } from './agent-session-orca-stop' import { agentJournalItemKey } from './agent-session-journal-item-key' import type { AgentJournalRenderItem } from './agent-session-journal-types' import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' @@ -41,8 +42,9 @@ function cutTurn(outcome?: string): AgentJournalRenderItem { } } -/** The host's row, as a host this build or a newer one writes it. */ -function stopRow(failure: unknown, scoped = true): 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. */ +function stopRow(orcaStop: { cause: string } | undefined, scoped = true): AgentJournalRenderItem { return { itemId: agentJournalItemKey({ provider: 'orca', @@ -51,32 +53,35 @@ function stopRow(failure: unknown, scoped = true): AgentJournalRenderItem { revision: 1, sequence: 3, observedAt: 6, - body: { kind: 'status', text: LEGACY_TEXT, tone: 'error', failure }, + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + agentName: 'Codex', + surface: 'row' + }), + tone: 'error', + ...(orcaStop === undefined ? {} : { orcaStop }) + }, ...(scoped ? { turnScope: { kind: 'turn' as const, turnItemId: turnId } } : {}) } } -const updateFact = agentSessionFailureFact('providerExited', { orcaStop: { cause: 'update' } }) +const update = { cause: 'update' } describe('the cause on a stopped row', () => { it('is read when known and dropped when a newer host names one this build does not know', () => { - expect(readAgentSessionFailureFact(updateFact)?.orcaStop).toEqual({ cause: 'update' }) - expect( - readAgentSessionFailureFact({ kind: 'providerExited', orcaStop: { cause: 'power-loss' } }) - ).toEqual({ kind: 'providerExited' }) + expect(readAgentSessionOrcaStop(update)).toEqual({ cause: 'update' }) + expect(readAgentSessionOrcaStop({ cause: 'power-loss' })).toBeUndefined() }) - it("keeps today's sentence, so a client that reads no cause prints the same row", () => { - expect(agentSessionFailureWords(updateFact, { agentName: 'Codex', surface: 'row' }).text).toBe( - LEGACY_TEXT - ) + it("rides beside today's sentence, so a client that reads no cause prints the same row", () => { + expect(stopRow(update).body).toMatchObject({ text: LEGACY_TEXT, tone: 'error' }) }) }) 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", () => { - // An older client keeps only the kind (as this build does for an unknown cause). - const items = [userMessage(1), cutTurn(), stopRow({ kind: 'providerExited' })] + const items = [userMessage(1), cutTurn(), stopRow(update)] const read = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) expect(read).toBe(items) expect(read.filter((item) => item.body.kind === 'status')).toHaveLength(1) @@ -85,36 +90,30 @@ describe('a client that predates the cause', () => { describe('the cut Continue answers', () => { it('is the latest turn when an Orca stop cut it and nothing was sent since', () => { - expect( - latestNativeChatOrcaStopCut([userMessage(1), cutTurn(), stopRow(updateFact)], []) - ).toEqual({ turnItemId: turnId, cause: 'update' }) + expect(latestNativeChatOrcaStopCut([userMessage(1), cutTurn(), stopRow(update)], [])).toEqual({ + turnItemId: turnId, + cause: 'update' + }) }) it('is gone once a message follows the cut', () => { expect( - latestNativeChatOrcaStopCut( - [userMessage(1), cutTurn(), stopRow(updateFact), userMessage(4)], - [] - ) + latestNativeChatOrcaStopCut([userMessage(1), cutTurn(), stopRow(update), userMessage(4)], []) ).toBeNull() }) it('is gone while a send is on its way', () => { expect( latestNativeChatOrcaStopCut( - [userMessage(1), cutTurn(), stopRow(updateFact)], + [userMessage(1), cutTurn(), stopRow(update)], [{ dispatchState: 'pending' }] ) ).toBeNull() }) it("is none for a stop with no Orca cause, a person's Stop, or an unscoped row", () => { - expect( - latestNativeChatOrcaStopCut([cutTurn(), stopRow({ kind: 'providerExited' })], []) - ).toBeNull() - expect( - latestNativeChatOrcaStopCut([cutTurn('cancellation'), stopRow(updateFact)], []) - ).toBeNull() - expect(latestNativeChatOrcaStopCut([cutTurn(), stopRow(updateFact, false)], [])).toBeNull() + expect(latestNativeChatOrcaStopCut([cutTurn(), stopRow(undefined)], [])).toBeNull() + expect(latestNativeChatOrcaStopCut([cutTurn('cancellation'), stopRow(update)], [])).toBeNull() + expect(latestNativeChatOrcaStopCut([cutTurn(), stopRow(update, false)], [])).toBeNull() }) }) diff --git a/src/shared/native-chat-orca-stop-cut.ts b/src/shared/native-chat-orca-stop-cut.ts index 74604fd51259..0701a1d90188 100644 --- a/src/shared/native-chat-orca-stop-cut.ts +++ b/src/shared/native-chat-orca-stop-cut.ts @@ -3,10 +3,7 @@ // 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 { - readAgentSessionFailureFact, - type AgentSessionOrcaStopCause -} from './agent-session-failure' +import { readAgentSessionOrcaStop, type AgentSessionOrcaStopCause } 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' @@ -26,11 +23,9 @@ export function orcaShutdownRowClientMessageId( /** Why Orca stopped, from a status row the host wrote about it; null for any other row. */ export function orcaStopCauseOfRow(item: AgentJournalRenderItem): AgentSessionOrcaStopCause | null { - if (item.body.kind !== 'status') { - return null - } - const fact = readAgentSessionFailureFact(item.body.failure) - return fact?.kind === 'providerExited' ? (fact.orcaStop?.cause ?? null) : null + return item.body.kind === 'status' + ? (readAgentSessionOrcaStop(item.body.orcaStop)?.cause ?? null) + : null } export type NativeChatOrcaStopCut = { diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index f48a31f7f38b..f4d190225d79 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -11,6 +11,7 @@ import type { AgentSessionBackgroundTaskRunState } from './agent-session-background-task-wire' import type { AgentSessionTokenUsage } from './agent-session-context-usage' +import type { AgentSessionOrcaStop } from './agent-session-orca-stop' import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageSendMode, @@ -59,6 +60,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 } } /** A tool invocation by the agent. `input` is the (already-serialized) tool diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 0c2173c0a2dc..3d6635a5246a 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -127,7 +127,7 @@ function itemBlocks(item: AgentJournalRenderItem): { if (body.kind !== 'status' || body.turnLifecycle) { return null } - return { role: 'system', blocks: [structuredAgentSessionStatusBlock(body)] } + return { role: 'system', blocks: [structuredAgentSessionStatusBlock(body, item.turnScope)] } } function isAgentJournalMessageSendMode(value: string): value is AgentJournalMessageSendMode { diff --git a/src/shared/structured-agent-session-status-block.ts b/src/shared/structured-agent-session-status-block.ts index 58dbbe9131c9..080f3c5de53b 100644 --- a/src/shared/structured-agent-session-status-block.ts +++ b/src/shared/structured-agent-session-status-block.ts @@ -1,18 +1,29 @@ import { readAgentSessionFailureFact } from './agent-session-failure' -import type { AgentJournalStatusItem } from './agent-session-journal-types' +import type { AgentJournalStatusItem, AgentJournalTurnScope } from './agent-session-journal-types' +import { readAgentSessionOrcaStop } from './agent-session-orca-stop' import type { NativeChatTextBlock } from './native-chat-types' /** A status row as the line a chat paints: named fields only, so a host-only key never leaks. */ export function structuredAgentSessionStatusBlock( - body: AgentJournalStatusItem + body: AgentJournalStatusItem, + turnScope?: AgentJournalTurnScope ): NativeChatTextBlock { const failure = readAgentSessionFailureFact(body.failure) + const orcaStop = readAgentSessionOrcaStop(body.orcaStop) return { type: 'text', text: body.text, ...(body.presentation !== undefined ? { presentation: body.presentation } : {}), ...(body.tone !== undefined ? { tone: body.tone } : {}), ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}), - ...(failure ? { failure } : {}) + ...(failure ? { failure } : {}), + ...(orcaStop + ? { + orcaStop: { + ...orcaStop, + ...(turnScope?.kind === 'turn' ? { turnItemId: turnScope.turnItemId } : {}) + } + } + : {}) } } From 680d56510811737afd75e06aa8ac0c5df36eb520 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:09:32 -0700 Subject: [PATCH 04/29] test(native-chat): type the Orca-stop test fixtures so the typecheck passes The cut turn's outcome takes the journal's outcome type, and the stand-in close reads the provider sink through a checked lookup instead of an index that may be absent. --- .../structured-agent-session-shutdown-cut-row.test.ts | 6 +++++- src/shared/native-chat-orca-stop-cut.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) 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 7bc4950739f6..8db31e3ec4d6 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 @@ -49,7 +49,11 @@ beforeEach(() => { adapter: { ...adapter(), closeSession: async () => { - state.acquire.mock.calls.at(-1)?.[0].events.appendItem( + const events = state.acquire.mock.calls.at(-1)?.[0]?.events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( CUT_TURN, { kind: 'turn', diff --git a/src/shared/native-chat-orca-stop-cut.test.ts b/src/shared/native-chat-orca-stop-cut.test.ts index 842545974726..0705f21e4eb5 100644 --- a/src/shared/native-chat-orca-stop-cut.test.ts +++ b/src/shared/native-chat-orca-stop-cut.test.ts @@ -3,7 +3,7 @@ import { agentSessionFailureFact } from './agent-session-failure' import { agentSessionFailureWords } from './agent-session-failure-words' import { readAgentSessionOrcaStop } from './agent-session-orca-stop' import { agentJournalItemKey } from './agent-session-journal-item-key' -import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentJournalRenderItem, AgentJournalTurnOutcome } from './agent-session-journal-types' import { withNativeChatCutTurnNotices } from './native-chat-cut-turn-notice' import { latestNativeChatOrcaStopCut, @@ -25,7 +25,7 @@ function userMessage(sequence: number): AgentJournalRenderItem { } } -function cutTurn(outcome?: string): AgentJournalRenderItem { +function cutTurn(outcome?: AgentJournalTurnOutcome): AgentJournalRenderItem { return { itemId: turnId, revision: 2, From 9ea4744b7483706a97cc19e7ad7c4a8785761fb2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:23:26 -0700 Subject: [PATCH 05/29] fix(native-chat): call a cut a crash only when Orca's runtime started and never ended A chat said "Orca stopped unexpectedly" whenever its runtime left no quit record, and only the desktop quit wrote one, so a headless server's restart or update, the Settings relaunch, and a Windows logoff all read as crashes. Each runtime now records its own start when its chat store opens, and every graceful exit records its end through one synchronous entry point: the desktop quit's teardown, the headless server's stop, the in-app relaunch, the GPU-fallback restarts, the update-install watchdog, and Windows session end. A crash is a runtime that started and never ended; a runtime with no readable record (never written, pruned, unreadable) names no cause, so the chat keeps its generic words. One file per runtime, written durably and only by that runtime, so a damaged file never blocks a later write and two processes never lose each other's record. --- src/main/ipc/app.test.ts | 16 +- src/main/ipc/app.ts | 3 + ...red-agent-session-shutdown-cut-row.test.ts | 23 ++- src/main/orcad/orcad-launch-contract.test.ts | 31 +++ src/main/orcad/orcad-lifecycle.ts | 3 + .../agent-session-record-store-file.ts | 8 +- .../agent-session-runtime-attribution.test.ts | 39 +++- .../agent-session-runtime-attribution.ts | 10 +- .../agent-session-runtime-end-record.test.ts | 131 ++++++++---- .../agent-session-runtime-end-record.ts | 188 ++++++++++++------ .../agent-session-store-transactions.ts | 16 +- ...ructured-agent-session-runtime-teardown.ts | 11 +- src/main/startup/gpu-lifecycle.ts | 4 + src/main/update-install-exit-watchdog.test.ts | 17 +- src/main/update-install-exit-watchdog.ts | 2 + ...eateMainWindow-tray-minimize-close.test.ts | 18 ++ src/main/window/createMainWindow.ts | 3 + 17 files changed, 378 insertions(+), 145 deletions(-) diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 07b8b82f9a76..b55149864900 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -12,7 +12,8 @@ const { showOpenDialogMock, trustFloatingWorkspaceDirectoryMock, registerRendererShutdownCheckpointHandlerMock, - registerMacKeyboardLayoutChangeNotificationsMock + registerMacKeyboardLayoutChangeNotificationsMock, + recordAgentSessionRuntimeEndMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), appExitMock: vi.fn(), @@ -24,7 +25,8 @@ const { showOpenDialogMock: vi.fn(), trustFloatingWorkspaceDirectoryMock: vi.fn(), registerRendererShutdownCheckpointHandlerMock: vi.fn(), - registerMacKeyboardLayoutChangeNotificationsMock: vi.fn() + registerMacKeyboardLayoutChangeNotificationsMock: vi.fn(), + recordAgentSessionRuntimeEndMock: vi.fn() })) vi.mock('node:child_process', () => ({ @@ -101,6 +103,10 @@ vi.mock('../app-relaunch', () => ({ relaunchApp: relaunchAppMock })) +vi.mock('../runtime/agent-session-runtime-end-record', () => ({ + recordAgentSessionRuntimeEnd: recordAgentSessionRuntimeEndMock +})) + vi.mock('./floating-workspace-directory', () => ({ ensureDefaultFloatingWorkspacePath: vi.fn(), trustFloatingWorkspaceDirectory: trustFloatingWorkspaceDirectoryMock, @@ -158,6 +164,7 @@ describe('registerAppHandlers', () => { trustFloatingWorkspaceDirectoryMock.mockReset() registerRendererShutdownCheckpointHandlerMock.mockReset() registerMacKeyboardLayoutChangeNotificationsMock.mockReset() + recordAgentSessionRuntimeEndMock.mockReset() for (const probe of Object.values(windowsProbes)) { probe.mockClear() } @@ -200,6 +207,11 @@ describe('registerAppHandlers', () => { expect(destroySystemTrayMock.mock.invocationCallOrder[0]).toBeLessThan( appExitMock.mock.invocationCallOrder[0] ) + // app.exit skips will-quit, so the relaunch records the runtime's end itself. + expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('quit') + expect(recordAgentSessionRuntimeEndMock.mock.invocationCallOrder[0]).toBeLessThan( + appExitMock.mock.invocationCallOrder[0] + ) }) it('waits for pre-relaunch cleanup before exiting', async () => { diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index d056727fdb71..e70c0e00ed3b 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -25,6 +25,7 @@ import { registerMacSymbolicHotkeysProbeHandler } from './macos-symbolic-hotkeys import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint' import { readMacKeyboardLayoutSnapshot } from './macos-keyboard-layout-snapshot' import { registerMacKeyboardLayoutChangeNotifications } from './macos-keyboard-layout-change-notifications' +import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record' const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox' @@ -302,6 +303,8 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp setTimeout(() => { // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. destroySystemTray() + // app.exit skips will-quit, whose teardown would otherwise record this. + recordAgentSessionRuntimeEnd('quit') relaunchApp('renderer-request') app.exit(0) }, 150) 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 8db31e3ec4d6..b8ddfb690fc7 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 @@ -2,6 +2,8 @@ // prints, with the cause beside them. 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' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -204,19 +206,26 @@ describe('the row a restart writes for a reply its Orca died in', () => { it('names the update an unfinished quit began, never a crash', async () => { await runningTurn() - // The quit wrote its first word, then died before it stopped the agent. - await recordAgentSessionRuntimeEnd( - hostTestState().root, - agentSessionRuntimeIncarnation(), - 'update', - HOST_TEST_NOW - ) + // The quit recorded its end, then died before it stopped the agent. + recordAgentSessionRuntimeEnd('update', HOST_TEST_NOW) await restartAfterDeath() const { stopRows } = await reread() expect(stopRows.map((row) => row.body)).toMatchObject([{ orcaStop: { cause: 'update' } }]) }) + + it('names no cause when the Orca before left no record of itself', async () => { + await runningTurn() + await rm(join(hostTestState().root, 'agent-session-runtimes'), { recursive: true, force: true }) + + await restartAfterDeath() + + const { stopRows, readerErrors } = await reread() + expect(stopRows).toEqual([]) + // The cut keeps today's words. + expect(readerErrors).toEqual([LEGACY_TEXT]) + }) }) describe('an agent this Orca runs', () => { diff --git a/src/main/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index 26f2f28cbed7..5c233610f92f 100644 --- a/src/main/orcad/orcad-launch-contract.test.ts +++ b/src/main/orcad/orcad-launch-contract.test.ts @@ -2,6 +2,9 @@ * The two things a supervisor reads off a launch: what the arguments mean, and what an exit * code means. Both are part of the ops contract in docs/reference/orcad-operations.md. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { ORCAD_EXIT_CONFIGURATION, @@ -10,6 +13,10 @@ import { resolveOrcadExitCode } from './orcad-entry' import { startOrcadWithLifecycle } from './orcad-lifecycle' +import { + beginAgentSessionRuntimeRecord, + readAgentSessionRuntimeEnds +} from '../runtime/agent-session-runtime-end-record' import { OrcadBindAddressError } from './orcad-bind-address' import { OrcadInstanceLockError } from './orcad-instance-lock' import { ProfileStateAccessError } from '../persistence/profile-state/profile-state-access' @@ -105,6 +112,30 @@ describe('orcad lifecycle cleanup', () => { expect(cleanupHost).toHaveBeenCalledOnce() }) + it("records the runtime's end before its stop waits on anything", async () => { + const directory = await mkdtemp(join(tmpdir(), 'orcad-runtime-end-')) + try { + beginAgentSessionRuntimeRecord(directory, 'orcad-runtime', 1) + let endSeenByCleanup: string | undefined + const handle = await startOrcadWithLifecycle( + async (registerCleanup) => { + registerCleanup(async () => { + endSeenByCleanup = readAgentSessionRuntimeEnds(directory)?.get('orcad-runtime') + }) + return {} + }, + vi.fn(async () => {}) + ) + + await handle.stop() + + // A chat whose agent dies with this stop reads as a restart, not a crash. + expect(endSeenByCleanup).toBe('quit') + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + it('keeps the host aware of failed runtime teardown so it cannot release profile admission', async () => { const failure = new Error('profile writer still running') const cleanupHost = vi.fn(async () => {}) diff --git a/src/main/orcad/orcad-lifecycle.ts b/src/main/orcad/orcad-lifecycle.ts index 6121838e2d4d..5faf5e10f233 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -3,6 +3,7 @@ import { resolveOrcadBrowserProvider } from './orcad-browser-provider' import { acquireOrcadInstanceLock } from './orcad-instance-lock' import { ORCAD_BUNDLED_LAUNCHER_ENV } from './orcad-bundled-runtime' import { resolveOrcadExitCode } from './orcad-exit-code' +import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record' import { acquireProfileStateRuntimeAdmission, type ProfileStateRuntimeAdmission @@ -63,6 +64,8 @@ export async function startOrcadWithLifecycle( ): Promise }> { let cleanupRuntime = async (): Promise => {} const cleanup = createIdempotentOrcadCleanup(async () => { + // First, before any wait: chats whose agent dies with this stop read as a restart, not a crash. + recordAgentSessionRuntimeEnd('quit') let runtimeCleanupSucceeded = false try { await cleanupRuntime() diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index b426706aed7d..248f3a20c1fc 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -17,7 +17,7 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' -import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' +import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-orca-stop' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -41,9 +41,9 @@ export type AgentSessionStoreState = { sessionTabs: AgentSessionTabTable | null /** Tab rows an index never recorded holds (chats opened while the import was owed). */ unrecordedSessionTabs?: AgentSessionTabTable - /** Each earlier runtime that began a quit here, and why; read once at load. Null or absent when - * that cannot be read, which attributes no death to Orca. */ - runtimeEnds?: ReadonlyMap | null + /** How each earlier runtime recorded here ended: its quit or update, or a crash when it started + * and never ended; read once at load. A runtime it lacks, or null, attributes no death to Orca. */ + runtimeEnds?: ReadonlyMap | null } export type LoadedAgentSessionStore = { diff --git a/src/main/runtime/agent-session-runtime-attribution.test.ts b/src/main/runtime/agent-session-runtime-attribution.test.ts index b05be4df724c..c17d69589b4e 100644 --- a/src/main/runtime/agent-session-runtime-attribution.test.ts +++ b/src/main/runtime/agent-session-runtime-attribution.test.ts @@ -2,7 +2,7 @@ // an owner tagged with an earlier runtime of this build can be attributed; anything unknown keeps // the death unattributed, so the chat keeps its generic words. -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -19,7 +19,10 @@ import { agentSessionRuntimeIncarnation, beginAgentSessionRuntimeIncarnationForTest } from './agent-session-runtime-attribution' -import { recordAgentSessionRuntimeEnd } from './agent-session-runtime-end-record' +import { + beginAgentSessionRuntimeRecord, + recordAgentSessionRuntimeEnd +} from './agent-session-runtime-end-record' const SESSION = 'session-alpha-1' const OWNER = { @@ -54,6 +57,14 @@ async function seedHeldBy(runtime: string | null, lease: Partial ({ outcome: 'pid-absent' }), now: 90_000 }) @@ -61,8 +72,9 @@ async function restartEvidence() { } describe("an agent that died with Orca's previous runtime", () => { - it('died in a crash when that runtime began no quit', async () => { + it('died in a crash when that runtime started and never ended', async () => { await seedHeldBy('runtime-a') + recorded('runtime-a') expect(await restartEvidence()).toMatchObject({ kind: 'pid-absent', runtimeEnd: 'crash' }) }) @@ -70,20 +82,28 @@ describe("an agent that died with Orca's previous runtime", () => { 'died with the %s that runtime began, when the quit did not finish', async (trigger) => { await seedHeldBy('runtime-a') - await recordAgentSessionRuntimeEnd(directory, 'runtime-a', trigger, 50_000) + recorded('runtime-a', trigger) expect(await restartEvidence()).toMatchObject({ runtimeEnd: trigger }) } ) - it("is never told by another runtime's quit", async () => { + it("is never told by another runtime's end", async () => { await seedHeldBy('runtime-b') - await recordAgentSessionRuntimeEnd(directory, 'runtime-a', 'update', 50_000) + recorded('runtime-b') + recorded('runtime-a', 'update') expect(await restartEvidence()).toMatchObject({ runtimeEnd: 'crash' }) }) - it('names no cause when the quit records cannot be read', async () => { + it('names no cause when that runtime left no record', async () => { + await seedHeldBy('runtime-a') + recorded('runtime-b', 'quit') + expect(await restartEvidence()).not.toHaveProperty('runtimeEnd') + }) + + it("names no cause when that runtime's record cannot be read", async () => { await seedHeldBy('runtime-a') - await writeFile(join(directory, 'agent-session-runtime-ends.json'), '{not json') + await mkdir(join(directory, 'agent-session-runtimes'), { recursive: true }) + await writeFile(join(directory, 'agent-session-runtimes', 'runtime-a.json'), '{not json') expect(await restartEvidence()).not.toHaveProperty('runtimeEnd') }) @@ -99,6 +119,7 @@ describe("an agent that died with Orca's previous runtime", () => { it('is told once a survivor that outlived the runtime is stopped and proven gone', async () => { await seedHeldBy('runtime-a') + recorded('runtime-a') const store = await openTestAgentSessionRecordStore(directory) // The owner was still alive at restart, so it waits in recovery for its stop. await store.reconcileOnRestart({ @@ -121,6 +142,8 @@ describe("an agent that died with Orca's previous runtime", () => { describe('an agent this runtime ran', () => { it("becomes an earlier runtime's after a relaunch", async () => { await seedHeldBy(agentSessionRuntimeIncarnation()) + // This runtime's store opened, so it recorded its start; it then died without a quit. + await openTestAgentSessionRecordStore(directory) beginAgentSessionRuntimeIncarnationForTest() expect(await restartEvidence()).toMatchObject({ runtimeEnd: 'crash' }) }) diff --git a/src/main/runtime/agent-session-runtime-attribution.ts b/src/main/runtime/agent-session-runtime-attribution.ts index efaf072aee17..3e52ecb93fbd 100644 --- a/src/main/runtime/agent-session-runtime-attribution.ts +++ b/src/main/runtime/agent-session-runtime-attribution.ts @@ -1,8 +1,8 @@ // Which Orca runtime held each chat's agent, and, when that agent is later proven dead, whether it // died with that runtime and how the runtime ended. Applied to every store transaction, so a death // any transition proves (a restart's adjudication, recovery's stop of a survivor) is told the same -// way. When anything is unknown (an owner an older build recorded, a terminal's claim, unreadable -// quit records) the death names no cause and the chat keeps its generic words. +// way. When anything is unknown (an owner an older build recorded, a terminal's claim, a runtime +// with no readable record) the death names no cause and the chat keeps its generic words. import { randomUUID } from 'node:crypto' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -46,7 +46,11 @@ function attributed( ) { return after } - const runtimeEnd = runtimeEnds.get(heldBy) ?? 'crash' + // Unrecorded, pruned or unreadable: no cause. A crash is only a runtime that started and never ended. + const runtimeEnd = runtimeEnds.get(heldBy) + if (!runtimeEnd) { + return after + } return { ...after, lease: { ...after.lease, deathEvidence: { ...deathEvidence, runtimeEnd } } } } diff --git a/src/main/runtime/agent-session-runtime-end-record.test.ts b/src/main/runtime/agent-session-runtime-end-record.test.ts index 48bf881f809e..6370fb930124 100644 --- a/src/main/runtime/agent-session-runtime-end-record.test.ts +++ b/src/main/runtime/agent-session-runtime-end-record.test.ts @@ -1,34 +1,24 @@ -// The word a quitting runtime leaves: written before anything the quit waits on, and never able to -// hold the quit, however slow or broken the disk. +// What a runtime records about itself: a start when its store opens, and its end on a graceful exit. +// A crash is only a runtime known to have started and never ended; anything else names no cause. -import { mkdtemp, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type * as NodeFsPromises from 'node:fs/promises' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const fsMocks = vi.hoisted(() => ({ hangWrites: false })) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - writeFile: (...args: Parameters) => - fsMocks.hangWrites ? new Promise(() => {}) : actual.writeFile(...args) - } -}) - import { + beginAgentSessionRuntimeRecord, readAgentSessionRuntimeEnds, recordAgentSessionRuntimeEnd } from './agent-session-runtime-end-record' import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown' -import { agentSessionRuntimeIncarnation } from './agent-session-runtime-attribution' + +const RUNTIME_A = '0000000a-0000-4000-8000-000000000000' +const RUNTIME_B = '0000000b-0000-4000-8000-000000000000' let directory: string beforeEach(async () => { - fsMocks.hangWrites = false directory = await mkdtemp(join(tmpdir(), 'orca-runtime-end-')) }) @@ -36,36 +26,94 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -describe('the word a quitting runtime leaves', () => { - it('is kept per runtime, newest last', async () => { - await recordAgentSessionRuntimeEnd(directory, 'runtime-a', 'quit', 1) - await recordAgentSessionRuntimeEnd(directory, 'runtime-b', 'update', 2) +function runtimesDirectory(): string { + return join(directory, 'agent-session-runtimes') +} + +describe('what a runtime records about itself', () => { + it('reads as a crash once it started and never ended', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('crash') + }) + + it.each(['quit', 'update'] as const)('reads as the %s it recorded as it ended', (trigger) => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + recordAgentSessionRuntimeEnd(trigger, 2) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe(trigger) + }) + + it('keeps the first end it recorded', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + recordAgentSessionRuntimeEnd('update', 2) + recordAgentSessionRuntimeEnd('quit', 3) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('update') + }) + + it('names no cause for a runtime it has no record of', () => { + expect(readAgentSessionRuntimeEnds(directory)).toEqual(new Map()) + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + expect(readAgentSessionRuntimeEnds(directory)?.has(RUNTIME_B)).toBe(false) + }) + + it('names no cause for a record it cannot read, and the next start writes its own', async () => { + await mkdir(runtimesDirectory(), { recursive: true }) + await writeFile(join(runtimesDirectory(), `${RUNTIME_A}.json`), '{not json') + expect(readAgentSessionRuntimeEnds(directory)?.has(RUNTIME_A)).toBe(false) + + beginAgentSessionRuntimeRecord(directory, RUNTIME_B, 1) + recordAgentSessionRuntimeEnd('quit', 2) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_B)).toBe('quit') + }) + + it('names no cause when the records cannot be listed', async () => { + await writeFile(runtimesDirectory(), 'not a directory') + expect(readAgentSessionRuntimeEnds(directory)).toBeNull() + }) + + it('keeps only the newest records, so a pruned runtime names no cause', async () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + // The oldest record by far. + await utimes(join(runtimesDirectory(), `${RUNTIME_A}.json`), 1, 1) + for (let index = 0; index < 16; index += 1) { + beginAgentSessionRuntimeRecord( + directory, + `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + index + ) + } + const ends = readAgentSessionRuntimeEnds(directory) + expect(ends?.size).toBe(16) + expect(ends?.has(RUNTIME_A)).toBe(false) + }) + + it("never loses another runtime's record to its own writes", () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + beginAgentSessionRuntimeRecord(directory, RUNTIME_B, 2) + recordAgentSessionRuntimeEnd('quit', 3) expect(readAgentSessionRuntimeEnds(directory)).toEqual( new Map([ - ['runtime-a', 'quit'], - ['runtime-b', 'update'] + [RUNTIME_A, 'crash'], + [RUNTIME_B, 'quit'] ]) ) }) - it('is empty before any runtime quit here', () => { - expect(readAgentSessionRuntimeEnds(directory)).toEqual(new Map()) - }) - - it('never fails the quit when it cannot be written', async () => { - await expect( - recordAgentSessionRuntimeEnd(join(directory, 'missing'), 'runtime-a', 'quit', 1) - ).resolves.toBeUndefined() + it('never fails the exit when it cannot be written', async () => { + await writeFile(runtimesDirectory(), 'not a directory') + expect(() => beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1)).not.toThrow() + expect(() => recordAgentSessionRuntimeEnd('quit', 2)).not.toThrow() }) - it('never holds the quit on a disk that does not answer', async () => { - fsMocks.hangWrites = true - await expect( - recordAgentSessionRuntimeEnd(directory, 'runtime-a', 'quit', 1, 20) - ).resolves.toBeUndefined() + it('leaves no temp files behind', async () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + recordAgentSessionRuntimeEnd('quit', 2) + expect(await readdir(runtimesDirectory())).toEqual([`${RUNTIME_A}.json`]) }) +}) - it('is written before the quit waits on anything, so a quit that never finishes is still a quit', async () => { +describe('a quit', () => { + it('records its end before it waits on anything, so a quit that never finishes is still a quit', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) const installed = { host: { stopDelivery: vi.fn(), flushAllStreamedEvents: vi.fn() }, adapter: { closeAll: vi.fn() }, @@ -76,11 +124,8 @@ describe('the word a quitting runtime leaves', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown reads only the members stubbed above before it parks on the recovery wait. void tearDownRuntime(installed as unknown as InstalledRuntime, 'update') - await vi.waitFor(() => - expect(readAgentSessionRuntimeEnds(directory)?.get(agentSessionRuntimeIncarnation())).toBe( - 'update' - ) - ) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('update') expect(installed.host.flushAllStreamedEvents).not.toHaveBeenCalled() + expect(existsSync(join(runtimesDirectory(), `${RUNTIME_A}.json`))).toBe(true) }) }) diff --git a/src/main/runtime/agent-session-runtime-end-record.ts b/src/main/runtime/agent-session-runtime-end-record.ts index 5429bbb7ca2e..cc6bc67a7662 100644 --- a/src/main/runtime/agent-session-runtime-end-record.ts +++ b/src/main/runtime/agent-session-runtime-end-record.ts @@ -1,89 +1,151 @@ -// The durable word each Orca runtime leaves as it begins quitting: which runtime, and why (a quit -// or an update). A later start reads it to tell an owner that died with a quit that did not finish -// from one that died in a crash. Keyed by runtime, never deleted, and capped, so no later run can -// read an earlier run's word as its own. +// What each Orca runtime that ran chats here says about itself: that it started, and, when it ends +// gracefully, how (a quit or an update). A later start reads them to tell an owner that died with a +// quit from one that died in a crash. A crash is only ever concluded from a runtime known to have +// started and never ended; a runtime with no readable record names no cause. // -// Written apart from the chat database on purpose: that database is synchronous SQLite, and a -// stalled disk there would park the quit's event loop. This write is asynchronous and bounded, and -// its failure only costs that distinction. +// One file per runtime, written only by that runtime, so two processes sharing the directory never +// lose each other's word to a read-modify-write. Pruned to the newest few, and losing a record only +// costs the distinction. -import { readFileSync } from 'node:fs' -import { readFile, rename, writeFile } from 'node:fs/promises' +import { mkdirSync, readdirSync, rmSync, statSync } from 'node:fs' import { join } from 'node:path' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' +import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' +import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-orca-stop' import { AGENT_SESSION_RESUME_TRIGGERS, type AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' -import { withTimeout } from '../../shared/promise-timeout-fallback' -const RUNTIME_ENDS_FILE = 'agent-session-runtime-ends.json' -/** Enough runtimes that an owner a few restarts old still finds its runtime's word. */ -const MAX_RUNTIME_ENDS = 16 -export const RUNTIME_END_RECORD_TIMEOUT_MS = 1_000 +const RUNTIMES_DIRECTORY = 'agent-session-runtimes' +/** Enough runtimes that an owner a few restarts old still finds its runtime's record. */ +const MAX_RUNTIME_RECORDS = 16 +const MAX_RUNTIME_RECORD_BYTES = 4 * 1024 +/** A temp file older than this belongs to a writer that died between write and rename. */ +const STALE_TEMP_FILE_MS = 60_000 +const RECORD_FILE = /^([A-Za-z0-9-]{1,64})\.json$/ -type RuntimeEnd = { runtime: string; trigger: AgentSessionResumeTrigger; at: number } +type RuntimeRecord = { + runtime: string + startedAt: number + end?: { trigger: AgentSessionResumeTrigger; at: number } +} + +/** This process's runtime, once its store opened: the one record a graceful end rewrites. */ +let current: { path: string; record: RuntimeRecord } | null = null + +function writeRecord(path: string, record: RuntimeRecord): void { + writeFileDurableSync(durableWriteTempPath(path), path, JSON.stringify(record)) +} + +function prune(directory: string, keep: string): void { + const records: { name: string; modifiedAt: number }[] = [] + for (const name of readdirSync(directory)) { + const modifiedAt = statSync(join(directory, name), { throwIfNoEntry: false })?.mtimeMs ?? 0 + if (name.endsWith('.tmp')) { + if (Date.now() - modifiedAt > STALE_TEMP_FILE_MS) { + rmSync(join(directory, name), { force: true }) + } + } else if (name !== keep) { + records.push({ name, modifiedAt }) + } + } + records.sort((a, b) => b.modifiedAt - a.modifiedAt) + for (const { name } of records.slice(MAX_RUNTIME_RECORDS - 1)) { + rmSync(join(directory, name), { force: true }) + } +} + +/** Records that this runtime started. Never throws: a runtime with no record reads as unknown. */ +export function beginAgentSessionRuntimeRecord( + stateDirectory: string, + runtime: string, + now: number +): void { + const directory = join(stateDirectory, RUNTIMES_DIRECTORY) + const name = `${runtime}.json` + current = { path: join(directory, name), record: { runtime, startedAt: now } } + try { + mkdirSync(directory, { recursive: true }) + writeRecord(current.path, current.record) + prune(directory, name) + } catch { + // Unwritten, this runtime's owners name no cause when they die. + } +} + +/** + * The one "this Orca runtime is ending" entry point, for every graceful exit. Synchronous, so an + * exit that cannot await still records it; the first call wins. Never throws. + */ +export function recordAgentSessionRuntimeEnd( + trigger: AgentSessionResumeTrigger, + now = Date.now() +): void { + if (!current || current.record.end) { + return + } + current.record = { ...current.record, end: { trigger, at: now } } + try { + writeRecord(current.path, current.record) + } catch { + // Unwritten, a death of this runtime's owners reads as a crash. + } +} function readTrigger(value: unknown): AgentSessionResumeTrigger | undefined { return AGENT_SESSION_RESUME_TRIGGERS.find((trigger) => trigger === value) } -function runtimeEndsOf(raw: string): RuntimeEnd[] { - const parsed: unknown = JSON.parse(raw) - const ends = - typeof parsed === 'object' && parsed !== null && 'ends' in parsed ? parsed.ends : null - if (!Array.isArray(ends)) { - throw new Error('agent session runtime ends are unreadable') - } - return ends.flatMap((end: unknown) => { - if (typeof end !== 'object' || end === null) { - return [] +/** How a recorded runtime ended: its quit or update, else a crash. Undefined when unreadable. */ +function readRecordedEnd(path: string, runtime: string): AgentSessionOrcaStopCause | undefined { + try { + const parsed: unknown = JSON.parse( + readNodeFileSyncWithinLimit(path, MAX_RUNTIME_RECORD_BYTES).buffer.toString('utf8') + ) + if ( + typeof parsed !== 'object' || + parsed === null || + !('runtime' in parsed) || + parsed.runtime !== runtime || + !('startedAt' in parsed) || + typeof parsed.startedAt !== 'number' + ) { + return undefined + } + if (!('end' in parsed) || parsed.end === undefined) { + return 'crash' } - const runtime = 'runtime' in end ? end.runtime : undefined - const trigger = readTrigger('trigger' in end ? end.trigger : undefined) - const at = 'at' in end ? end.at : undefined - return typeof runtime === 'string' && trigger && typeof at === 'number' - ? [{ runtime, trigger, at }] - : [] - }) + const end = parsed.end + return readTrigger( + typeof end === 'object' && end !== null && 'trigger' in end ? end.trigger : undefined + ) + } catch { + return undefined + } } /** - * Each recorded runtime's quit trigger. Empty when no runtime ever began a quit here; null when the - * word cannot be read, so nothing is ever called a crash on a failed read. + * How each recorded runtime ended. A runtime missing from the map (never recorded, pruned, or + * unreadable) names no cause. Null when the records cannot be listed at all. */ export function readAgentSessionRuntimeEnds( stateDirectory: string -): ReadonlyMap | null { +): ReadonlyMap | null { + const directory = join(stateDirectory, RUNTIMES_DIRECTORY) + let names: string[] try { - const ends = runtimeEndsOf(readFileSync(join(stateDirectory, RUNTIME_ENDS_FILE), 'utf8')) - return new Map(ends.map((end) => [end.runtime, end.trigger])) + names = readdirSync(directory) } catch (error) { return error instanceof Error && 'code' in error && error.code === 'ENOENT' ? new Map() : null } -} - -async function appendRuntimeEnd(stateDirectory: string, end: RuntimeEnd): Promise { - const path = join(stateDirectory, RUNTIME_ENDS_FILE) - const earlier = await readFile(path, 'utf8').then(runtimeEndsOf, () => []) - const ends = [...earlier.filter((entry) => entry.runtime !== end.runtime), end].slice( - -MAX_RUNTIME_ENDS - ) - const temporary = `${path}.${process.pid}.tmp` - await writeFile(temporary, JSON.stringify({ ends })) - await rename(temporary, path) -} - -/** Resolves once the word landed, failed, or ran out of time; never rejects. */ -export function recordAgentSessionRuntimeEnd( - stateDirectory: string, - runtime: string, - trigger: AgentSessionResumeTrigger, - now: number, - timeoutMs = RUNTIME_END_RECORD_TIMEOUT_MS -): Promise { - return withTimeout( - appendRuntimeEnd(stateDirectory, { runtime, trigger, at: now }).catch(() => undefined), - timeoutMs, - undefined - ) + const ends = new Map() + for (const name of names) { + const runtime = RECORD_FILE.exec(name)?.[1] + const end = runtime ? readRecordedEnd(join(directory, name), runtime) : undefined + if (runtime && end) { + ends.set(runtime, end) + } + } + return ends } diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 4a4b03c0f448..d5a6a4153a11 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -13,8 +13,14 @@ import { journalOpenRefusalError } from '../native-chat/agent-session-journal/jo import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' import type { AgentSessionStoreState } from './agent-session-record-store-file' import { writeAgentSessionStoreRows } from './agent-session-record-rows' -import { attributeAgentSessionRuntime } from './agent-session-runtime-attribution' -import { readAgentSessionRuntimeEnds } from './agent-session-runtime-end-record' +import { + agentSessionRuntimeIncarnation, + attributeAgentSessionRuntime +} from './agent-session-runtime-attribution' +import { + beginAgentSessionRuntimeRecord, + readAgentSessionRuntimeEnds +} from './agent-session-runtime-end-record' import { agentSessionStoreDraftRowWrites, draftAgentSessionStoreState, @@ -85,6 +91,12 @@ export class AgentSessionStoreTransactions { ...loaded, runtimeEnds: readAgentSessionRuntimeEnds(journalDatabase.stateDirectory) } + // Before any owner this runtime records: a crash is concluded only from a recorded start. + beginAgentSessionRuntimeRecord( + journalDatabase.stateDirectory, + agentSessionRuntimeIncarnation(), + Date.now() + ) } /** The committed state. A transaction in flight never shows here until its rows have landed. */ diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 0ad1a0afbf46..bb0bf39ff7fe 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -7,7 +7,6 @@ import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import { agentSessionRuntimeIncarnation } from './agent-session-runtime-attribution' import { recordAgentSessionRuntimeEnd } from './agent-session-runtime-end-record' export type InstalledRuntime = { @@ -38,20 +37,14 @@ export async function tearDownRuntime( trigger: AgentSessionResumeTrigger ): Promise { // First, before any wait: should this quit not finish, the next start must still know it was a - // quit and not a crash. Bounded and never rejects. - const runtimeEndRecorded = recordAgentSessionRuntimeEnd( - installed.journalDatabase.stateDirectory, - agentSessionRuntimeIncarnation(), - trigger, - Date.now() - ) + // quit and not a crash. + recordAgentSessionRuntimeEnd(trigger) // An exit settled while recovery drains wakes delivery, which would start a fresh child for // teardown to kill; queued messages wait for the next launch instead. installed.host.stopDelivery() // Drain an in-flight recovery before stopping children; recovery may still // be writing lifecycle rows or acquiring a replacement child. await installed.waitForRecovery() - await runtimeEndRecorded const failures: unknown[] = [] // Host teardown runs FIRST, which inverts the older order. It is what stops this host's // provider children now: it evicts each owned session through the adapter, and that eviction diff --git a/src/main/startup/gpu-lifecycle.ts b/src/main/startup/gpu-lifecycle.ts index 89055aa2ba3e..26dc468c68c5 100644 --- a/src/main/startup/gpu-lifecycle.ts +++ b/src/main/startup/gpu-lifecycle.ts @@ -24,6 +24,7 @@ import { } from './windows-install-dir-acl-recovery' import { mainProcessState as state, gpuFallbackEnvironment } from './main-process-state' import { createGpuAccelerationAboutPanelOptions } from '../menu/gpu-acceleration-about-panel' +import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record' export function updateGpuAccelerationAboutPanel(): void { app.setAboutPanelOptions( @@ -127,6 +128,8 @@ export async function presentGpuFallbackRecoveredLaunchPrompt( crashesInWindow: marker.crashesInWindow }) destroySystemTray() + // app.exit skips will-quit, whose teardown would otherwise record this. + recordAgentSessionRuntimeEnd('quit') app.exit(0) } }) @@ -255,6 +258,7 @@ export async function handleGpuChildCrash( state.isQuitting = true relaunchApp('gpu-fallback', fallbackData) destroySystemTray() + recordAgentSessionRuntimeEnd('quit') app.exit(0) } } diff --git a/src/main/update-install-exit-watchdog.test.ts b/src/main/update-install-exit-watchdog.test.ts index 80457230a3c8..43fc6d369b4d 100644 --- a/src/main/update-install-exit-watchdog.test.ts +++ b/src/main/update-install-exit-watchdog.test.ts @@ -1,14 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { appMock, recordUpdaterLifecycleMock } = vi.hoisted(() => ({ - appMock: { exit: vi.fn() }, - recordUpdaterLifecycleMock: vi.fn() -})) +const { appMock, recordUpdaterLifecycleMock, recordAgentSessionRuntimeEndMock } = vi.hoisted( + () => ({ + appMock: { exit: vi.fn() }, + recordUpdaterLifecycleMock: vi.fn(), + recordAgentSessionRuntimeEndMock: vi.fn() + }) +) vi.mock('electron', () => ({ app: appMock })) vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: recordUpdaterLifecycleMock })) +vi.mock('./runtime/agent-session-runtime-end-record', () => ({ + recordAgentSessionRuntimeEnd: recordAgentSessionRuntimeEndMock +})) import { armUpdateInstallExitWatchdog, @@ -21,6 +27,7 @@ describe('update install exit watchdog', () => { vi.useFakeTimers() appMock.exit.mockClear() recordUpdaterLifecycleMock.mockClear() + recordAgentSessionRuntimeEndMock.mockClear() }) afterEach(() => { @@ -36,6 +43,8 @@ describe('update install exit watchdog', () => { vi.advanceTimersByTime(1) expect(appMock.exit).toHaveBeenCalledExactlyOnceWith(0) + // A wedged teardown may not have reached its own record of the update. + expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('update') expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( 'install_exit_watchdog_fired', { timeoutMs: UPDATE_INSTALL_EXIT_TIMEOUT_MS }, diff --git a/src/main/update-install-exit-watchdog.ts b/src/main/update-install-exit-watchdog.ts index 69986d1154ea..4517b5f41c5f 100644 --- a/src/main/update-install-exit-watchdog.ts +++ b/src/main/update-install-exit-watchdog.ts @@ -1,5 +1,6 @@ import { app } from 'electron' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' +import { recordAgentSessionRuntimeEnd } from './runtime/agent-session-runtime-end-record' // Why 20s: comfortably above a healthy shutdown (renderer buffer capture, // daemon final checkpoints, bounded 2s telemetry flush) but bounded — a wedged @@ -35,6 +36,7 @@ export function armUpdateInstallExitWatchdog(timeoutMs = UPDATE_INSTALL_EXIT_TIM ) // Why exit(0): the quit is already committed and cleanup is wedged, not // failed — a clean code keeps ShipIt/launchd on the normal relaunch path. + recordAgentSessionRuntimeEnd('update') app.exit(0) }, timeoutMs) // Why unref: the watchdog must never be the thing keeping the process alive. diff --git a/src/main/window/createMainWindow-tray-minimize-close.test.ts b/src/main/window/createMainWindow-tray-minimize-close.test.ts index 469d83138304..faba9c9606a1 100644 --- a/src/main/window/createMainWindow-tray-minimize-close.test.ts +++ b/src/main/window/createMainWindow-tray-minimize-close.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { recordAgentSessionRuntimeEndMock } = vi.hoisted(() => ({ + recordAgentSessionRuntimeEndMock: vi.fn() +})) + vi.mock('electron', async () => (await import('./createMainWindow-test-harness')).electronModuleMock() ) @@ -13,6 +17,9 @@ vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harnes vi.mock('../browser/browser-manager', async () => (await import('./createMainWindow-test-harness')).browserManagerMock() ) +vi.mock('../runtime/agent-session-runtime-end-record', () => ({ + recordAgentSessionRuntimeEnd: recordAgentSessionRuntimeEndMock +})) import { createMainWindow } from './createMainWindow' import { ipcMain } from 'electron' @@ -140,6 +147,17 @@ describe('createMainWindow', () => { ]) }) + it("records the Orca runtime's end, since Windows emits no will-quit for it", () => { + setPlatform('win32') + recordAgentSessionRuntimeEndMock.mockClear() + const { windowHandlers } = setupCloseWindow() + + createMainWindow(null) + windowHandlers['session-end']?.({ reasons: ['logoff'] } as never) + + expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('quit') + }) + it.each(['darwin', 'linux'] as const)( 'does not mark session teardown state on %s', (platform) => { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 41d84d1ab93b..89c01dff078b 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -31,6 +31,7 @@ import { import { installMainWindowWebviewSecurity } from './main-window-webview-security' import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation' import { installWindowsPathRegistryChangeListener } from '../pty/windows-path-registry-change' +import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record' export { WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS } @@ -155,6 +156,8 @@ export function createMainWindow( ? event.reasons.filter((reason) => typeof reason === 'string').join(',') : '' }) + // Windows emits no will-quit for an OS shutdown or logoff, so its teardown never records it. + recordAgentSessionRuntimeEnd('quit') }) } From 13e4ba5e1db7af7d4fc6a116a44c2de5009e60d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:30:49 -0700 Subject: [PATCH 06/29] fix(native-chat): Continue answers once Orca accepts it, not once the agent has started On a paired server, Continue waited for the agent to start before answering, and the client gives a paired call 15 s. A slow start (account switch, login shell, a long resume) showed "Couldn't continue this chat" while the agent was in fact continuing. Continue now answers when Orca has accepted the message, as a send does. The agent's start and answer settle afterwards, and a start that fails is the chat's own note, as before. The restart dialog's batch still waits for the handover, which is where it counts a start as done. The verdict helpers move to their own module to keep the continuation file within its size limit. --- ...ured-agent-session-continuation-verdict.ts | 47 +++++++++++ ...t-session-interrupted-continuation.test.ts | 42 ++++++++++ ...-agent-session-interrupted-continuation.ts | 7 +- ...ured-agent-session-restart-continuation.ts | 83 ++++++------------- 4 files changed, 119 insertions(+), 60 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-continuation-verdict.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-continuation-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-continuation-verdict.ts new file mode 100644 index 000000000000..9a75c6b0ed5a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-continuation-verdict.ts @@ -0,0 +1,47 @@ +// What a continuation's send came to, read from its submission: the four dispatch states, never +// collapsed into transport success (`StructuredAgentSessionContinuationOutcome`). + +import { + readAgentSessionFailureFact, + type UnreadAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' + +export type ContinuationSubmission = { + dispatchState?: string + reason?: string | null + rejection?: UnreadAgentSessionFailureFact +} + +export function refusedBy( + sessionId: string, + submission: ContinuationSubmission +): StructuredAgentSessionContinuationOutcome { + // A start the agent was refused files that refusal's code, which the failure guidance keys on. + const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal + return { + sessionId, + outcome: 'refused', + reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', + ...(refusal ? { refusal } : {}) + } +} + +export function verdictOf( + sessionId: string, + submission: ContinuationSubmission | undefined +): StructuredAgentSessionContinuationOutcome { + const dispatch = submission?.dispatchState + if (submission && dispatch === 'rejected') { + return refusedBy(sessionId, submission) + } + if (dispatch === 'pending') { + // Still pending after settlement gave up: handed off, never confirmed. + return { sessionId, outcome: 'pending' } + } + // `unknown`, or a peer that reported no state at all: delivery is unverifiable, so this claims + // neither success nor failure — and writes no note saying the agent was asked to continue. + return dispatch === 'accepted' + ? { sessionId, outcome: 'continued' } + : { sessionId, outcome: 'unknown' } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts index 80e607f4f894..aedab816612f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts @@ -6,6 +6,7 @@ import { expect, it, vi } from 'vitest' import { AGENT_SESSION_RESTART_CONTINUATION_MESSAGE, AGENT_SESSION_RESTART_CONTINUATION_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, restartContinuationMessage } from '../../../shared/agent-session-restart-continuation' import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' @@ -142,3 +143,44 @@ it('continues when the restart offer cannot be read', async () => { await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) }) + +it('answers once Orca accepted it, while the agent is still starting', async () => { + const { host, acquire, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + const start = acquire.getMockImplementation() + if (!start) { + throw new Error('the harness acquire has no implementation') + } + let finishStarting!: () => void + const starting = new Promise((resolve) => { + finishStarting = resolve + }) + // An agent start slower than a paired client's wait for the answer. + acquire.mockImplementationOnce(async (input) => { + await starting + return start(input) + }) + + const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'pending' }) + expect(dispatch).not.toHaveBeenCalled() + finishStarting() + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) +}) + +it('says in the chat when the agent cannot start after the click was answered', async () => { + const { host, acquire } = await interruptedRestart() + const turnItemId = await cutTurn(host) + acquire.mockRejectedValueOnce(new Error('the agent could not start')) + + const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'pending' }) + await vi.waitFor(async () => + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts index 9ef2606d831b..49420832226e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts @@ -62,14 +62,15 @@ export async function continueInterruptedStructuredAgentSessionTurn( const continuationId = interruptedContinuationId(sessionId, turnItemId, host.now(), input.nonce) try { const started = await startStructuredAgentSessionContinuation( - continuationDeps(host, stillCut), + // As a send answers: a paired client's wait is shorter than an agent's start can be. + { ...continuationDeps(host, stillCut), answerAtAcceptance: true }, sessionId, input.offer ?? {}, continuationId ) if ('verdict' in started) { - // Handed to its agent: the provider's answer can take a whole turn, and the chat's own note - // reports it, so the click is answered now. + // Accepted: the agent's start and answer can take a whole turn, and the chat's own note + // reports a failure, so the click is answered now. void started.verdict().catch(() => { host.logger.warn('settling an interrupted-turn continuation failed', { scope: 'interrupted-continuation', 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 e5d6fc2c1bb7..e5d50f99837e 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 @@ -10,14 +10,15 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - readAgentSessionFailureFact, - type UnreadAgentSessionFailureFact -} from '../../../shared/agent-session-failure' import { agentSessionRefusalReference, type AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + refusedBy, + verdictOf, + type ContinuationSubmission +} from './structured-agent-session-continuation-verdict' import { agentSessionSendSubmission, type AgentSessionMutationEnvelope, @@ -155,12 +156,6 @@ export class RestartContinuationSupersededError extends AgentSessionPreDispatchE } } -type ContinuationSubmission = { - dispatchState?: string - reason?: string | null - rejection?: UnreadAgentSessionFailureFact -} - export type StructuredAgentSessionContinuationDeps = { /** Runtime fence as it stands now; null when this host has no record of the session. */ currentFence: (sessionId: string) => number | null @@ -198,6 +193,8 @@ export type StructuredAgentSessionContinuationDeps = { /** Where a note that could not be written is reported. The note is best effort, but its failure * is not allowed to be silent — a swallowed append is how this regressed unnoticed once already. */ logger: StructuredAgentSessionLogger + /** Answer once Orca accepted the message, as a send does, not once the agent took it. */ + answerAtAcceptance?: true } /** A continuation handed to its agent, or already decided. */ @@ -207,8 +204,9 @@ export type StartedStructuredAgentSessionContinuation = /** * Sends the continuation to ONE session and returns once the agent has taken it or its start - * failed — the point a restart batch counts a start as done. What the provider then answered is - * the `verdict`, awaited separately so a slow answer does not hold the batch. + * failed — the point a restart batch counts a start as done — or, with `answerAtAcceptance`, once + * Orca accepted it. What the provider then answered is the `verdict`, awaited separately so a slow + * answer does not hold the batch. */ export async function startStructuredAgentSessionContinuation( deps: StructuredAgentSessionContinuationDeps, @@ -327,54 +325,25 @@ async function sendContinuation( } } const clientMessageId = envelope.clientOperationId - const handedOver = await deps.awaitHandedOver(sessionId, clientMessageId).catch(() => undefined) + const handOver = () => deps.awaitHandedOver(sessionId, clientMessageId).catch(() => undefined) + const settle = async (handedOver: ContinuationSubmission | undefined) => + verdictOf( + sessionId, + // The send result carries the dispatch as it stood when Orca took the message, which for + // a normal successful send is `pending`, so the settled value is what decides. + (await deps.awaitSettlement(sessionId, clientMessageId).catch(() => undefined)) ?? + handedOver ?? + sent.value?.submission + ) + if (deps.answerAtAcceptance) { + // The agent's start can outlast the caller's wait; a start refused is the verdict's note. + return { verdict: async () => settle(await handOver()) } + } + const handedOver = await handOver() if (handedOver?.dispatchState === 'rejected') { return { done: refusedBy(sessionId, handedOver) } } - return { - verdict: async () => - verdictOf( - sessionId, - // The send result carries the dispatch as it stood when Orca took the message, which for - // a normal successful send is `pending`, so the settled value is what decides. - (await deps.awaitSettlement(sessionId, clientMessageId).catch(() => undefined)) ?? - handedOver ?? - sent.value?.submission - ) - } -} - -function refusedBy( - sessionId: string, - submission: ContinuationSubmission -): StructuredAgentSessionContinuationOutcome { - // A start the agent was refused files that refusal's code, which the failure guidance keys on. - const refusal = readAgentSessionFailureFact(submission.rejection)?.refusal - return { - sessionId, - outcome: 'refused', - reason: refusal?.code ?? submission.reason ?? 'agent_session_dispatch_rejected', - ...(refusal ? { refusal } : {}) - } -} - -function verdictOf( - sessionId: string, - submission: ContinuationSubmission | undefined -): StructuredAgentSessionContinuationOutcome { - const dispatch = submission?.dispatchState - if (submission && dispatch === 'rejected') { - return refusedBy(sessionId, submission) - } - if (dispatch === 'pending') { - // Still pending after settlement gave up: handed off, never confirmed. - return { sessionId, outcome: 'pending' } - } - // `unknown`, or a peer that reported no state at all: delivery is unverifiable, so this claims - // neither success nor failure — and writes no note saying the agent was asked to continue. - return dispatch === 'accepted' - ? { sessionId, outcome: 'continued' } - : { sessionId, outcome: 'unknown' } + return { verdict: () => settle(handedOver) } } /** Without the error: a failed append can carry the chat's private recovery payload. */ From cdb457edf24737c15f1f7383aac5007526b7a7bf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:34:23 -0700 Subject: [PATCH 07/29] fix(native-chat): a reply the user steered, or a command run after the cut, still offers Continue The cut detector stopped at the first user message after the cut turn, so a steer the turn had taken, or a conversation command such as /context run after the cut, removed Continue while the row still named the cause. The rule for what is no request of its own (a conversation command, a row its turn produced, a send handed into a running turn) moves out of the latest-request reader into one shared predicate, which both that reader and the cut detector use. The host's "still wanted?" check reads the same detector, so the client and host agree. --- ...t-session-interrupted-continuation.test.ts | 22 ++++++++++ src/shared/native-chat-orca-stop-cut.test.ts | 41 +++++++++++++++++++ src/shared/native-chat-orca-stop-cut.ts | 13 ++++-- ...structured-agent-session-latest-request.ts | 23 +++++++---- 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts index aedab816612f..320f45ccab16 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts @@ -184,3 +184,25 @@ it('says in the chat when the agent cannot start after the click was answered', }) ) }) + +it('continues a reply the user had steered before Orca stopped', async () => { + const { host, dispatch, store, marker } = await interruptedRestart() + const turnItemId = await cutTurn(host) + const fence = store.getRecord(SESSION)?.lease.runtimeFence + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (fence === undefined || fence === null || !journal) { + throw new Error('the restarted chat has no open journal') + } + // A steer the cut turn took: the client still offers Continue, so the host must agree. + await journal.appendItem( + { provider: 'orca', clientMessageId: 'steer' }, + hostTestMessage('also update the tests'), + { fence, turnScope: { kind: 'turn', turnItemId } } + ) + + const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect(answer).toEqual({ sessionId: SESSION, outcome: 'pending' }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts(dispatch)).toEqual([restartContinuationMessage(marker!)]) +}) diff --git a/src/shared/native-chat-orca-stop-cut.test.ts b/src/shared/native-chat-orca-stop-cut.test.ts index 0705f21e4eb5..728b4a0d50aa 100644 --- a/src/shared/native-chat-orca-stop-cut.test.ts +++ b/src/shared/native-chat-orca-stop-cut.test.ts @@ -102,6 +102,47 @@ describe('the cut Continue answers', () => { ).toBeNull() }) + it('stays for a steer the cut turn took, which asked for nothing new', () => { + const steer: AgentJournalRenderItem = { + ...userMessage(4), + turnScope: { kind: 'turn', turnItemId: turnId } + } + expect( + latestNativeChatOrcaStopCut([userMessage(1), cutTurn(), stopRow(update), steer], []) + ).toEqual({ turnItemId: turnId, cause: 'update' }) + }) + + it('stays across a conversation command run after the cut, and its turn', () => { + const command: AgentJournalRenderItem = { + ...userMessage(4), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/context' }], + command: { name: 'context' } + } + } + const commandTurn: AgentJournalRenderItem = { + itemId: agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cmd', ordinal: 1 }), + revision: 1, + sequence: 5, + observedAt: 7, + body: { + kind: 'turn', + turnId: 'cmd', + state: 'completed', + outcome: 'success', + userItemId: command.itemId + } + } + expect( + latestNativeChatOrcaStopCut( + [userMessage(1), cutTurn(), stopRow(update), command, commandTurn], + [] + ) + ).toEqual({ turnItemId: turnId, cause: 'update' }) + }) + it('is gone while a send is on its way', () => { expect( latestNativeChatOrcaStopCut( diff --git a/src/shared/native-chat-orca-stop-cut.ts b/src/shared/native-chat-orca-stop-cut.ts index 0701a1d90188..cab5825ea6e1 100644 --- a/src/shared/native-chat-orca-stop-cut.ts +++ b/src/shared/native-chat-orca-stop-cut.ts @@ -9,6 +9,8 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-ses import { STALE_SESSION_ROW_PREFIX } from './agent-session-stop-row-identity' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' import { agentTurnVerdict } from './agent-turn-outcome' +import { structuredAgentSessionCommandTurnItemIds } from './structured-agent-session-command-entry' +import { isStructuredAgentSessionNonRequestRow } from './structured-agent-session-latest-request' /** The row a quit or update writes for the turn it cut, keyed by the child the quit stopped, so a * retried stop of that child writes no second row. The prefix is one older clients already read @@ -34,8 +36,9 @@ export type NativeChatOrcaStopCut = { } /** - * The latest root turn, when an Orca stop cut it and nothing was sent since: no message after it, - * and no send still on its way. Null otherwise. + * The latest root turn, when an Orca stop cut it and nothing was sent since: no request after it, + * and no send still on its way. A steer into that turn or a conversation command asks for nothing + * new (`isStructuredAgentSessionNonRequestRow`). Null otherwise. */ export function latestNativeChatOrcaStopCut( items: readonly AgentJournalRenderItem[], @@ -44,10 +47,14 @@ export function latestNativeChatOrcaStopCut( if (submissions.some((submission) => submission.dispatchState === 'pending')) { return null } + const commandTurns = structuredAgentSessionCommandTurnItemIds(items) let turnIndex = -1 for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index]! - if (!isRootAgentJournalItem(item)) { + if ( + !isRootAgentJournalItem(item) || + isStructuredAgentSessionNonRequestRow(item, commandTurns) + ) { continue } if (item.body.kind === 'message' && item.body.role === 'user') { diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 35856a21552d..6fd8f8e0bf08 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -38,6 +38,19 @@ export type StructuredAgentSessionLatestRequest = { settledAt: number | undefined } +/** A root row that is no request of its own: a conversation command or a row its turn produced, or + * a send its handover placed inside a running turn (a steer), which that turn answers for. */ +export function isStructuredAgentSessionNonRequestRow( + item: Pick, + commandTurnItemIds: ReadonlySet +): boolean { + return ( + commandTurnItemIds.has(item.itemId) || + isStructuredAgentSessionCommandRow(item, commandTurnItemIds) || + (item.body.kind === 'message' && item.body.role === 'user' && item.turnScope?.kind === 'turn') + ) +} + /** Null when the journal holds no request with a verdict to give. Accepted and unanswered sends * are passed over — the session is working until their turn records — and so are sends that * failed nobody (withdrawn, or left undelivered by a restart or a close). */ @@ -53,8 +66,7 @@ export function latestStructuredAgentSessionRequest( if ( !item || !isRootAgentJournalItem(item) || - commandTurns.has(item.itemId) || - isStructuredAgentSessionCommandEntry(item.body) + isStructuredAgentSessionNonRequestRow(item, commandTurns) ) { continue } @@ -69,12 +81,7 @@ export function latestStructuredAgentSessionRequest( } } const submission = rejected.get(item.itemId) - if ( - submission && - classifyDispatchRejection(submission).verdict === 'failure' && - // Handed into a running turn (a steer): that turn answers for it. - item.turnScope?.kind !== 'turn' - ) { + if (submission && classifyDispatchRejection(submission).verdict === 'failure') { return { kind: 'refused-send', id: item.itemId, From 00f24b022e9c4dda1dd717498b97c92d3df38f71 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:37:01 -0700 Subject: [PATCH 08/29] fix(native-chat): a death proven after an earlier settle explains the turn it ends When a chat was read before the restart proved its old agent dead, the read could only call the turn unverifiable. The proof then revised the turn to interrupted, but the death row was scoped by the turn still marked running, and none was, so it landed on the conversation instead of the turn. That cut never offered Continue, and the row did not name it as the turn's explanation. The row is now scoped to the newest root turn the settle actually ends, running or revised. --- ...tured-agent-session-crash-turn-end.test.ts | 37 +++++++++++++++++++ ...gent-session-dead-generation-settlement.ts | 4 +- ...tructured-agent-session-exit-turn-scope.ts | 16 +++++--- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index d31a79e5f54b..33675655ee67 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 @@ -39,6 +39,8 @@ import { 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 { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' +import { beginAgentSessionRuntimeRecord } from '../../runtime/agent-session-runtime-end-record' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -357,6 +359,41 @@ describe('a turn a read reached before the reconcile proved its owner dead', () ).toEqual({ key: 'workedFor', duration: '27s' }) }) + it('explains the turn its proof revised, so the cut reads once and offers Continue', async () => { + // The Orca that crashed recorded that it started, and never that it ended. + const crashed = crashedClaudeRecord() + const ownerProcess = crashed.lease.ownerProcess + if (!ownerProcess) { + throw new Error('the crashed record has no owner') + } + await seedTestAgentSessionRecordStore(root, { + records: [ + { + ...crashed, + lease: { ...crashed.lease, ownerProcess: { ...ownerProcess, runtime: 'runtime-crashed' } } + } + ] + }) + beginAgentSessionRuntimeRecord(root, 'runtime-crashed', TOOL_STARTED_AT - 60_000) + store = await openTestAgentSessionRecordStore(root) + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) + + await host.reconcileRestartLeases() + await drainSession() + + const { items } = await host.journalSnapshot(SESSION) + const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId + expect(items.flatMap((item) => (item.body.kind === 'status' ? [item.turnScope] : []))).toEqual([ + { kind: 'turn', turnItemId } + ]) + expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' }) + const readerRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).filter( + (item) => item.body.kind === 'status' + ) + expect(readerRows).toHaveLength(1) + }) + it('revises nothing twice, whoever re-runs the settle', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) await host.history({ sessionId: SESSION, direction: 'tail' }) 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 8899d6eb5541..e0365c82ce3d 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 @@ -36,7 +36,7 @@ import { } from './structured-agent-session-stale-turn-verdict' import { exitedRootTurnScope, - runningRootTurnScope + settledRootTurnScope } from './structured-agent-session-exit-turn-scope' import { orcaStopRowBody } from './structured-agent-session-orca-stop-row' import { @@ -268,7 +268,7 @@ export async function settleStaleStructuredAgentSessionState(input: { // The death evidence is Orca's log text, never a sentence for a person: the row says only // that the provider stopped, and how Orca ended when the provider died with it. body: orcaStopRowBody(input.failureTextContext, evidence.runtimeEnd), - turnScope: runningRootTurnScope(items) + turnScope: settledRootTurnScope(items, turnEnds) }) } for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts index c336a1d3eca2..ff53b6492a1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts @@ -7,6 +7,8 @@ import { type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' /** The turn the exit ended: still running, or already ended at the exit's instant by the child's @@ -26,11 +28,13 @@ export function exitedRootTurnScope( return ended ? { kind: 'turn', turnItemId: ended.itemId } : AGENT_JOURNAL_THREAD_SCOPE } -export function runningRootTurnScope( - items: readonly AgentJournalRenderItem[] +/** The newest root turn a settle ends: one still running, or one an earlier settle left + * `unverifiable` that a proof now revises, whose row no longer reads running. */ +export function settledRootTurnScope( + items: readonly AgentJournalRenderItem[], + turnEnds: readonly JournalLifecycleMutationInput[] ): AgentJournalTurnScope { - const running = items.findLast( - (item) => isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running' - ) - return running ? { kind: 'turn', turnItemId: running.itemId } : AGENT_JOURNAL_THREAD_SCOPE + const ended = new Set(turnEnds.map((mutation) => agentJournalItemKey(mutation.identity))) + const settled = items.findLast((item) => isRootAgentJournalItem(item) && ended.has(item.itemId)) + return settled ? { kind: 'turn', turnItemId: settled.itemId } : AGENT_JOURNAL_THREAD_SCOPE } From 1a3a68633671a00b21f57da9845ffad308423d4a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:39:42 -0700 Subject: [PATCH 09/29] fix(native-chat): the cause row's words stay put, and Continue waits out a resume already running The row's "You can continue in this conversation." came and went with the button: it showed while a paired host's answer was still on its way, vanished when the button appeared, and came back the moment Continue was clicked. Continue also appeared on chats the restart prompt or the launch's own resume was already carrying on. The row now drops that sentence wherever the chat's host can continue a cut, counting a host that has not answered yet as able (a host that writes cause rows has Continue), so its words never change on screen. Continue is hidden while a resume is carrying that chat on. --- .../NativeChatInterruptedContinue.test.tsx | 39 ++++++++++++++++--- .../NativeChatInterruptedContinue.tsx | 23 +++++++---- .../native-chat/NativeChatNoticeRow.test.tsx | 12 +++--- .../native-chat/NativeChatNoticeRow.tsx | 7 +--- .../native-chat-orca-stop-context.ts | 8 ++-- .../native-chat-orca-stop-words.ts | 8 ++-- 6 files changed, 66 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 215866ea5e4e..b86b1b985a5f 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -5,7 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const hostLabel = (): string | null => 'studio-mac' - return { call: vi.fn(), supported: true, hostLabel: hostLabel() } + const capability = (): 'unknown' | 'supported' | 'unsupported' => 'supported' + const resuming: readonly string[] = [] + return { call: vi.fn(), capability: capability(), hostLabel: hostLabel(), resuming } }) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) @@ -13,7 +15,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call })) vi.mock('@/runtime/structured-agent-session-host-capability', () => ({ - useStructuredAgentSessionHostCapability: () => mocks.supported + useStructuredAgentSessionHostCapabilityState: () => mocks.capability +})) +vi.mock('../native-chat-resume-on-restart-store', () => ({ + useNativeChatRestartResuming: () => mocks.resuming })) vi.mock('./use-structured-agent-session-host-label', () => ({ useStructuredAgentSessionHostLabel: () => mocks.hostLabel @@ -70,7 +75,8 @@ function Harness(props: Props): React.JSX.Element { }) return ( - {continuation.view.continueTurnItemId ?? 'none'} + {continuation.offeredTurnItemId ?? 'none'} + {String(continuation.view.continueAvailable)} ) @@ -79,7 +85,8 @@ function Harness(props: Props): React.JSX.Element { const continueButton = () => screen.queryByRole('button', { name: 'Continue' }) beforeEach(() => { - mocks.supported = true + mocks.capability = 'supported' + mocks.resuming = [] mocks.hostLabel = 'studio-mac' mocks.call.mockReset() }) @@ -111,7 +118,29 @@ describe('Continue on a reply an Orca stop cut off', () => { }) it('is not offered by a host without the operation; the user continues by sending', () => { - mocks.supported = false + mocks.capability = 'unsupported' + render() + expect(continueButton()).toBeNull() + expect(screen.getByTestId('available')).toHaveTextContent('false') + }) + + it('tells the rows the same thing before the host answers, after it answers, and after a click', () => { + mocks.capability = 'unknown' + mocks.call.mockResolvedValue({ outcome: 'pending' }) + const { rerender } = render() + expect(screen.getByTestId('available')).toHaveTextContent('true') + expect(continueButton()).toBeNull() + + mocks.capability = 'supported' + rerender() + expect(screen.getByTestId('available')).toHaveTextContent('true') + fireEvent.click(continueButton()!) + + expect(screen.getByTestId('available')).toHaveTextContent('true') + }) + + it('is not offered while the restart prompt or the launch is resuming this chat', () => { + mocks.resuming = ['session-1'] render() expect(continueButton()).toBeNull() }) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index b41607a9416f..36045e53adef 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { useStructuredAgentSessionHostCapability } from '@/runtime/structured-agent-session-host-capability' +import { useStructuredAgentSessionHostCapabilityState } from '@/runtime/structured-agent-session-host-capability' import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { AgentJournalRenderItem, @@ -14,12 +14,15 @@ import { latestNativeChatOrcaStopCut } from '../../../../shared/native-chat-orca import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { NativeChatOrcaStopView } from './native-chat-orca-stop-context' import { useStructuredAgentSessionHostLabel } from './use-structured-agent-session-host-label' +import { useNativeChatRestartResuming } from '../native-chat-resume-on-restart-store' type ContinueAnswer = { outcome?: string } export type NativeChatInterruptedContinuation = { - /** What the chat's rows are told: the machine's name and the cut Continue is offered on. */ + /** What the chat's rows are told: the machine's name and whether its host can continue a cut. */ view: NativeChatOrcaStopView + /** The cut turn Continue is offered on right now, if any. */ + offeredTurnItemId: string | null continueNow: () => void } @@ -38,10 +41,12 @@ export function useNativeChatInterruptedContinuation(input: { }): NativeChatInterruptedContinuation { const { target, sessionId, onError } = input const hostLabel = useStructuredAgentSessionHostLabel(target) - const supported = useStructuredAgentSessionHostCapability( + const capability = useStructuredAgentSessionHostCapabilityState( target, AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY ) + // The restart prompt or the launch's own resume is already carrying this chat on. + const resuming = useNativeChatRestartResuming().includes(sessionId) const cut = useMemo( () => latestNativeChatOrcaStopCut(input.journalItems, input.submissions), [input.journalItems, input.submissions] @@ -49,7 +54,9 @@ export function useNativeChatInterruptedContinuation(input: { // The cut this client already asked to continue: hidden until the journal shows what came of it. const [asked, setAsked] = useState(null) const offered = - supported && cut && !input.isWorking && asked !== cut.turnItemId ? cut.turnItemId : null + capability === 'supported' && cut && !input.isWorking && !resuming && asked !== cut.turnItemId + ? cut.turnItemId + : null const continueNow = (): void => { if (offered === null) { return @@ -75,9 +82,11 @@ export function useNativeChatInterruptedContinuation(input: { } ) } + // Unknown counts as able: a host that writes cause rows has Continue, and the words stay put. + const continueAvailable = capability !== 'unsupported' // One object per change, so the chat's rows re-render only when what they show changes. - const view = useMemo(() => ({ hostLabel, continueTurnItemId: offered }), [hostLabel, offered]) - return { view, continueNow } + const view = useMemo(() => ({ hostLabel, continueAvailable }), [hostLabel, continueAvailable]) + return { view, offeredTurnItemId: offered, continueNow } } export function NativeChatInterruptedContinue({ @@ -86,7 +95,7 @@ export function NativeChatInterruptedContinue({ continuation: NativeChatInterruptedContinuation }): React.JSX.Element | null { const explanationId = useId() - if (continuation.view.continueTurnItemId === null) { + if (continuation.offeredTurnItemId === null) { return null } const explanation = translate( diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index be7d92b24a71..c51251c33bd7 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -14,15 +14,15 @@ afterEach(cleanup) function orcaStopView( hostLabel: string | null, - continueTurnItemId: string | null + continueAvailable: boolean ): NativeChatOrcaStopView { - return { hostLabel, continueTurnItemId } + return { hostLabel, continueAvailable } } function renderStatus( body: AgentJournalStatusItem, hostLabel: string | null = null, - continueTurnItemId: string | null = null + continueAvailable = false ) { const [message] = projectStructuredItemsToNativeChat([ { @@ -34,7 +34,7 @@ function renderStatus( turnScope: { kind: 'turn', turnItemId: 'cut-turn' } } ]) - const view = orcaStopView(hostLabel, continueTurnItemId) + const view = orcaStopView(hostLabel, continueAvailable) return render( @@ -66,8 +66,8 @@ describe('the row an Orca stop leaves', () => { expect(screen.queryByText(LEGACY_TEXT)).toBeNull() }) - it('leaves the way on to Continue while Continue is offered on its turn', () => { - renderStatus(orcaStopRow('update'), 'studio-mac', 'cut-turn') + it('leaves the way on to Continue wherever its host can continue a cut', () => { + renderStatus(orcaStopRow('update'), 'studio-mac', true) expect( screen.getByText( 'Orca on studio-mac restarted for an update while this response was in progress.' diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index f825c0354b1a..b9838c89dcf2 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -92,13 +92,10 @@ export function NativeChatNoticeRow({ // The host's row about an Orca stop names the cause and the machine, muted: Orca stopped, not the // agent. With no machine to name it keeps the host's own words. const { orcaStop } = block - const { hostLabel, continueTurnItemId } = orcaStopView + const { hostLabel, continueAvailable } = orcaStopView const named = orcaStop !== undefined && hostLabel !== null const text = named - ? nativeChatOrcaStopRowText(orcaStop.cause, hostLabel, { - continueOffered: - orcaStop.turnItemId !== undefined && orcaStop.turnItemId === continueTurnItemId - }) + ? nativeChatOrcaStopRowText(orcaStop.cause, hostLabel, { continueAvailable }) : block.text const tone = named ? 'notice' : block.tone const Icon = diff --git a/src/renderer/src/components/native-chat/native-chat-orca-stop-context.ts b/src/renderer/src/components/native-chat/native-chat-orca-stop-context.ts index 280b7f788a16..216face98546 100644 --- a/src/renderer/src/components/native-chat/native-chat-orca-stop-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-orca-stop-context.ts @@ -1,16 +1,16 @@ import { createContext, useContext } from 'react' /** What a structured chat tells its rows about an Orca stop: the name of the machine whose Orca runs - * it, as Orca shows that host everywhere (null when it has none to show), and the cut turn Continue - * is offered on, if any. */ + * it, as Orca shows that host everywhere (null when it has none to show), and whether its host can + * continue a cut, which only a host known not to has no Continue for. */ export type NativeChatOrcaStopView = { hostLabel: string | null - continueTurnItemId: string | null + continueAvailable: boolean } export const NativeChatOrcaStopContext = createContext({ hostLabel: null, - continueTurnItemId: null + continueAvailable: false }) export function useNativeChatOrcaStopView(): NativeChatOrcaStopView { 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 7737f2e0561d..46e43e964ba5 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 @@ -26,14 +26,14 @@ const STOPPED: Record string> = ) } -/** The row's sentence. While Continue is offered beside it, the button is the way on, so the row - * does not say it again. */ +/** 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: { continueOffered: boolean } + options: { continueAvailable: boolean } ): string { - return options.continueOffered + return options.continueAvailable ? STOPPED[cause](machine) : joinSentences([ STOPPED[cause](machine), From 3c06c11f32bd2ca2933b51a117ef754b600b5c7c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:41:20 -0700 Subject: [PATCH 10/29] fix(native-chat): a refused Continue says so once, in the composer A Continue the host refused before accepting anything wrote a red note into the chat and brought the button back, so each retry added another identical note; a chat the host had no record of was refused with no word at all. Continue now reports every refusal the same way as a failed request: the existing composer line "Couldn't continue this chat. Try again, or send a message.", which a retry replaces rather than repeats. A refusal before acceptance writes no note. A failure after the message was accepted (the agent could not start) is still the chat's own note, as for any send. --- ...t-session-interrupted-continuation.test.ts | 19 ++++++++++++++ ...ured-agent-session-restart-continuation.ts | 9 ++++--- .../NativeChatInterruptedContinue.test.tsx | 19 ++++++++++++++ .../NativeChatInterruptedContinue.tsx | 25 ++++++++----------- 4 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts index 320f45ccab16..ee3f3901ea2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts @@ -21,6 +21,7 @@ import { statusNotes } from './structured-agent-session-restart-interruption-test-harness' import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { previousExitUnverifiableRefusal } from './structured-agent-session-child-close' type Restarted = Awaited> @@ -206,3 +207,21 @@ it('continues a reply the user had steered before Orca stopped', async () => { await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) expect(sentTexts(dispatch)).toEqual([restartContinuationMessage(marker!)]) }) + +it('answers a refusal before anything was accepted, and a retry adds no note to the chat', async () => { + const { host, dispatch } = await interruptedRestart() + const turnItemId = await cutTurn(host) + const notesBefore = await statusNotes(host) + vi.spyOn(host, 'send').mockResolvedValue({ + ok: false, + refusal: previousExitUnverifiableRefusal() + }) + + const first = await host.restartResume.continueInterrupted(SESSION, turnItemId) + const retry = await host.restartResume.continueInterrupted(SESSION, turnItemId) + + expect([first.outcome, retry.outcome]).toEqual(['refused', 'refused']) + // The click's answer is the one report; the chat stays as it was. + expect(await statusNotes(host)).toEqual(notesBefore) + expect(dispatch).not.toHaveBeenCalled() +}) 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 e5d50f99837e..c07aae5a719a 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 @@ -193,7 +193,8 @@ export type StructuredAgentSessionContinuationDeps = { /** Where a note that could not be written is reported. The note is best effort, but its failure * is not allowed to be silent — a swallowed append is how this regressed unnoticed once already. */ logger: StructuredAgentSessionLogger - /** Answer once Orca accepted the message, as a send does, not once the agent took it. */ + /** Answer once Orca accepted the message, as a send does, not once the agent took it. A refusal + * before acceptance is then the caller's to report, not a note: a retry adds none. */ answerAtAcceptance?: true } @@ -221,13 +222,15 @@ export async function startStructuredAgentSessionContinuation( started = await sendContinuation(deps, sessionId, marker, continuationId) } catch (error) { // The user's own message came first: nothing failed, so the chat says nothing. - if (!(error instanceof RestartContinuationSupersededError)) { + if (!(error instanceof RestartContinuationSupersededError) && !deps.answerAtAcceptance) { await noteNotContinued(deps, sessionId, 'refused') } throw error } if ('done' in started) { - await noteOutcome(deps, sessionId, started.done) + if (!(deps.answerAtAcceptance && started.done.outcome === 'refused')) { + await noteOutcome(deps, sessionId, started.done) + } return started } const { verdict } = started diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index b86b1b985a5f..152657522c7b 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -158,6 +158,25 @@ describe('Continue on a reply an Orca stop cut off', () => { expect(continueButton()).toBeNull() }) + it('says so once, in the composer, and offers it again when the host refuses', async () => { + mocks.call.mockResolvedValue({ outcome: 'refused', reason: 'agent_session_not_attached' }) + const onError = vi.fn() + render() + + fireEvent.click(continueButton()!) + await waitFor(() => expect(continueButton()).toBeInTheDocument()) + fireEvent.click(continueButton()!) + await waitFor(() => expect(continueButton()).toBeInTheDocument()) + + // One slot: each click clears it, and the refusal sets the same line again. + expect(onError.mock.calls).toEqual([ + [null], + ["Couldn't continue this chat. Try again, or send a message."], + [null], + ["Couldn't continue this chat. Try again, or send a message."] + ]) + }) + it('says so and offers it again when the request fails', async () => { mocks.call.mockRejectedValue(new Error('offline')) const onError = vi.fn() diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index 36045e53adef..0c27ce9b1d04 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -62,25 +62,22 @@ export function useNativeChatInterruptedContinuation(input: { return } const turnItemId = offered - const retryable = (): void => setAsked((current) => (current === turnItemId ? null : current)) + // Nothing was accepted: one line in the composer, which a retry replaces rather than repeats. + const failed = (): void => { + setAsked((current) => (current === turnItemId ? null : current)) + onError( + translate( + 'components.native-chat.interruptedContinue.failed', + "Couldn't continue this chat. Try again, or send a message." + ) + ) + } setAsked(turnItemId) onError(null) void callStructuredAgentSession(target, 'agentSession.continueInterrupted', { sessionId, turnItemId - }).then( - // Refused before anything was sent: the host's note says why, and Continue may try again. - (answer) => (answer.outcome === 'refused' ? retryable() : undefined), - () => { - retryable() - onError( - translate( - 'components.native-chat.interruptedContinue.failed', - "Couldn't continue this chat. Try again, or send a message." - ) - ) - } - ) + }).then((answer) => (answer.outcome === 'refused' ? failed() : undefined), failed) } // Unknown counts as able: a host that writes cause rows has Continue, and the words stay put. const continueAvailable = capability !== 'unsupported' From 3696cf2a9db9b9d05de232884950f7c982948145 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:48:34 -0700 Subject: [PATCH 11/29] fix(native-chat): the row naming Orca's stop carries its own presentation and never folds A client that re-words host rows it cannot name (the draft that makes these cuts read as interruptions) treated the cause row as an older red row and replaced its words, so the cause never showed there. The row was also folded away under its collapsed turn once shown muted. The host's cause row now names the presentation 'orca-stop' beside today's words, failure fact and red tone, so a client that predates both changes still prints exactly today's row, red and on screen, and a client that re-words unnamed rows passes it through. This build shows it muted, counts it as no failure (the reply it cut stays the turn's answer), and never folds it; the fold field becomes `explainsTurn`, as the other change names it. --- ...ured-journal-restore-turn-grouping.test.ts | 2 +- ...gent-session-orca-stop-row-readers.test.ts | 103 ++++++++++++++++++ .../structured-agent-session-orca-stop-row.ts | 14 ++- ...red-agent-session-shutdown-cut-row.test.ts | 1 + .../native-chat/NativeChatNoticeRow.test.tsx | 13 ++- .../native-chat/NativeChatNoticeRow.tsx | 4 +- .../native-chat-transcript-slots.test.ts | 25 +++++ .../native-chat-transcript-slots.ts | 16 ++- src/shared/agent-session-orca-stop.ts | 2 + src/shared/native-chat-turn-fold.test.ts | 6 +- src/shared/native-chat-turn-fold.ts | 12 +- 11 files changed, 177 insertions(+), 21 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts index 7c636545daff..c1ba93bba152 100644 --- a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -97,7 +97,7 @@ describe('grouping a Codex thread restored from full history', () => { rendersProse: true, outlivesTurn: false, reportsFailure: false, - reportsCompaction: false + explainsTurn: false })), settledTurnKeys: new Set(bars.settledTurns.keys()), expandedTurnKeys: new Set() diff --git a/src/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 new file mode 100644 index 000000000000..0e74bed19354 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts @@ -0,0 +1,103 @@ +// 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', + 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 3db6a686e665..1778f7e43977 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 @@ -3,7 +3,10 @@ // name it (`AgentSessionOrcaStop`). import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { isAgentSessionOrcaStopCause } from '../../../shared/agent-session-orca-stop' +import { + AGENT_SESSION_ORCA_STOP_PRESENTATION, + isAgentSessionOrcaStopCause +} from '../../../shared/agent-session-orca-stop' import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import { agentSessionFailureWords, @@ -26,8 +29,9 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo type OrcaStopRowJournal = Pick -/** The row about an owner gone from under a turn: today's words, which every client prints, and - * why Orca stopped when it was Orca, which a client that knows the cause names instead. */ +/** 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 @@ -39,7 +43,9 @@ export function orcaStopRowBody( surface: 'row' }), tone: 'error', - ...(isAgentSessionOrcaStopCause(orcaEnd) ? { orcaStop: { cause: orcaEnd } } : {}) + ...(isAgentSessionOrcaStopCause(orcaEnd) + ? { presentation: AGENT_SESSION_ORCA_STOP_PRESENTATION, orcaStop: { cause: orcaEnd } } + : {}) } } 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 b8ddfb690fc7..7b0c928768db 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 @@ -146,6 +146,7 @@ describe('the row a quit writes for the reply it cut', () => { text: LEGACY_TEXT, tone: 'error', failure: { kind: 'providerExited' }, + presentation: 'orca-stop', orcaStop: { cause: trigger } }) expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId }) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index c51251c33bd7..cab3bf873754 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -50,6 +50,7 @@ function orcaStopRow(cause: string): AgentJournalStatusItem { kind: 'status', text: LEGACY_TEXT, tone: 'error', + presentation: 'orca-stop', orcaStop: { cause } } } @@ -80,15 +81,19 @@ describe('the row an Orca stop leaves', () => { expect(screen.getByText(LEGACY_TEXT)).toBeInTheDocument() }) - it('keeps the host words when the chat has no machine to name', () => { + 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)).toBeInTheDocument() + expect(screen.getByText(LEGACY_TEXT).parentElement?.parentElement).toHaveClass( + 'text-muted-foreground' + ) }) it('keeps the host words for a stop Orca did not cause', () => { - const { orcaStop: _orcaStop, ...agentExit } = orcaStopRow('update') + const { orcaStop: _orcaStop, presentation: _presentation, ...agentExit } = orcaStopRow('update') renderStatus(agentExit, 'studio-mac') - expect(screen.getByText(LEGACY_TEXT)).toBeInTheDocument() + expect(screen.getByText(LEGACY_TEXT).parentElement?.parentElement).toHaveClass( + 'text-destructive' + ) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index b9838c89dcf2..2087eaaa42c2 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -13,6 +13,7 @@ import { import type { NativeChatTextBlock } from '../../../../shared/native-chat-types' import { useNativeChatOrcaStopView } from './native-chat-orca-stop-context' import { nativeChatOrcaStopRowText } from './native-chat-orca-stop-words' +import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' import { ProviderFrameRow } from './NativeChatTranscriptChrome' const HOST_STATUS_WORDS: Record string> = { @@ -97,7 +98,8 @@ export function NativeChatNoticeRow({ const text = named ? nativeChatOrcaStopRowText(orcaStop.cause, hostLabel, { continueAvailable }) : block.text - const tone = named ? 'notice' : block.tone + const tone = + named || block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ? 'notice' : block.tone const Icon = tone === 'warning' ? AlertTriangle 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 7841e2de1908..5e4d8844bf29 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 @@ -264,6 +264,31 @@ describe('a turn no message opened', () => { ]) }) + // Stored red for clients that predate it, 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'), + blocks: [ + { + type: 'text' as const, + text: 'Codex stopped while this response was in progress.', + tone: 'error', + presentation: 'orca-stop', + orcaStop: { cause: 'update' } + } + ] + } + const messages = [text('u1', 'go', 'user'), text('a1', 'Looking.'), toolRun('work'), orcaStop] + const slots = build(messages, { + turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } + }) + expect(slots.map((slot) => [slot.message.id, slot.folded])).toEqual([ + ['u1', false], + ['a1', false], + ['orca-stop', false] + ]) + }) + it('folds an error the agent recovered from behind the answer that followed it', () => { const messages = [text('u1', 'go', 'user'), failure('retry'), text('a1', 'Done.')] const slots = build(messages, { diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 343ee1c22a5c..2657dd9bfded 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 @@ -8,6 +8,7 @@ // with the same derivation the row itself renders from. import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' import { isBackgroundTaskBlock, isSubagentGroupBlock, @@ -127,9 +128,18 @@ export function buildNativeChatTranscriptSlots( outlivesTurn: message.blocks.some( (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) ), - reportsFailure: message.blocks.some((block) => block.type === 'text' && block.tone === 'error'), - reportsCompaction: message.blocks.some( - (block) => block.type === 'text' && block.presentation === 'compaction' + // A row about Orca's own stop is stored red for clients that predate it; it reports no failure. + reportsFailure: message.blocks.some( + (block) => + block.type === 'text' && + block.tone === 'error' && + block.presentation !== AGENT_SESSION_ORCA_STOP_PRESENTATION + ), + explainsTurn: message.blocks.some( + (block) => + block.type === 'text' && + (block.presentation === 'compaction' || + block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION) ) })) // Liveness is the turn's, not any one call's: the run at the frontier stays diff --git a/src/shared/agent-session-orca-stop.ts b/src/shared/agent-session-orca-stop.ts index 32abca4949d6..f3d452826486 100644 --- a/src/shared/agent-session-orca-stop.ts +++ b/src/shared/agent-session-orca-stop.ts @@ -3,6 +3,8 @@ // Open on the wire: a cause this build does not know reads as none, and the row keeps its words. 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. */ +export const AGENT_SESSION_ORCA_STOP_PRESENTATION = 'orca-stop' export type AgentSessionOrcaStopCause = (typeof AGENT_SESSION_ORCA_STOP_CAUSES)[number] export type AgentSessionOrcaStop = { cause: AgentSessionOrcaStopCause } diff --git a/src/shared/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index bbdff9366f15..1272a40eb5f3 100644 --- a/src/shared/native-chat-turn-fold.test.ts +++ b/src/shared/native-chat-turn-fold.test.ts @@ -12,7 +12,7 @@ function row(overrides: Partial = {}): NativeChatTurnFold rendersProse: true, outlivesTurn: false, reportsFailure: false, - reportsCompaction: false, + explainsTurn: false, ...overrides } } @@ -198,9 +198,9 @@ describe('nativeChatTurnFold', () => { const rows = [ row({ role: 'user' }), row(), - row({ role: 'system', reportsCompaction: true }), + row({ role: 'system', explainsTurn: true }), row(), - row({ role: 'system', reportsCompaction: true }) + row({ role: 'system', explainsTurn: true }) ] const { foldedRows } = nativeChatTurnFold({ rows, diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 1b502a7f6939..4edfa3f487d1 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -24,9 +24,10 @@ export type NativeChatTurnFoldRow = { /** Whether a system row reports a failure. It competes with prose to be the * turn's answer, so a turn that failed shows the error as its end. */ reportsFailure: boolean - /** Whether the row reports a compaction's result. It never folds: hiding it - * would leave the turn's status as the only trace the context was rewritten. */ - reportsCompaction: boolean + /** Whether the row explains what became of the turn: a compaction's result, or + * that the response was interrupted. It never folds: hiding it would leave the + * turn's status as the only trace of it. */ + explainsTurn: boolean } export type NativeChatTurnFold = { @@ -84,12 +85,13 @@ export function nativeChatTurnFold({ for (const [index, row] of rows.entries()) { const { turnKey } = row // Outside the fold by construction: the reader's own message anchors the - // turn, a roster or background-task row outlives it, and a compaction report explains it. + // turn, a roster or background-task row outlives it, and a compaction report or interruption + // notice explains it. if ( turnKey === undefined || row.role === 'user' || row.outlivesTurn || - row.reportsCompaction || + row.explainsTurn || !settledTurnKeys.has(turnKey) ) { continue From 9f1636bfa2f105c053d974539fb06809b1f4cc13 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:52:28 -0700 Subject: [PATCH 12/29] test(native-chat): pass the session-end event without a type assertion --- src/main/window/createMainWindow-tray-minimize-close.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/window/createMainWindow-tray-minimize-close.test.ts b/src/main/window/createMainWindow-tray-minimize-close.test.ts index faba9c9606a1..417991f8a0df 100644 --- a/src/main/window/createMainWindow-tray-minimize-close.test.ts +++ b/src/main/window/createMainWindow-tray-minimize-close.test.ts @@ -153,7 +153,7 @@ describe('createMainWindow', () => { const { windowHandlers } = setupCloseWindow() createMainWindow(null) - windowHandlers['session-end']?.({ reasons: ['logoff'] } as never) + windowHandlers['session-end']?.({ reasons: ['logoff'] }) expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('quit') }) From d1377dc8708b3dd1523645792665fdce4a838a45 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:09:32 -0700 Subject: [PATCH 13/29] test(native-chat): the row naming Orca's stop renders neutral, whoever re-presented it Pins the rendered tone on this build: the stored red row, and the same row after a reader re-presents it in the neutral tone with its presentation and cause kept, both render muted and never fold. The phone draws chat rows without tone styling, so it needs no change. --- .../native-chat/NativeChatNoticeRow.test.tsx | 21 +++++++++++++++++++ .../native-chat-transcript-slots.test.ts | 16 ++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index cab3bf873754..4f435c4d1b43 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -76,6 +76,27 @@ describe('the row an Orca stop leaves', () => { ).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', () => { + const represented: AgentJournalStatusItem = { + ...orcaStopRow('crash'), + text: 'This response was interrupted. You can continue in this conversation.', + tone: 'notice' + } + renderStatus(represented, 'studio-mac', true) + expect( + screen.getByText( + 'Orca on studio-mac stopped unexpectedly while this response was in progress.' + ).parentElement?.parentElement + ).toHaveClass('text-muted-foreground') + cleanup() + renderStatus(represented, null, true) + expect( + screen.getByText('This response was interrupted. You can continue in this conversation.') + .parentElement?.parentElement + ).toHaveClass('text-muted-foreground') + }) + 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() 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 5e4d8844bf29..a37eaa439e0a 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 @@ -287,6 +287,22 @@ describe('a turn no message opened', () => { ['a1', false], ['orca-stop', false] ]) + // The same, once a reader re-presented it neutral. + const neutral: NativeChatMessage = { + ...orcaStop, + blocks: orcaStop.blocks.map((block) => + block.type === 'text' ? { ...block, tone: 'notice' } : block + ) + } + expect( + build([...messages.slice(0, 3), neutral], { + turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } + }).map((slot) => [slot.message.id, slot.folded]) + ).toEqual([ + ['u1', false], + ['a1', false], + ['orca-stop', false] + ]) }) it('folds an error the agent recovered from behind the answer that followed it', () => { From 37aa332202692b589ee78140585b666900b4adde Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:11:45 -0700 Subject: [PATCH 14/29] fix(native-chat): the "Couldn't continue" line goes once the chat is continued The composer line a failed or refused Continue set stayed on screen while the agent carried on: after an answer lost in transit, or once another client or the restart prompt continued the chat. Only the next Continue click or the user's own send cleared it, and a click also wiped an unrelated composer error. The line is now derived: shown only while the chat still sits on the cut that Continue failed on, so it goes as soon as the journal shows the chat continued, from anywhere. A Continue click clears only its own line, and a retry answered "already continued" leaves none. --- .../NativeChatInterruptedContinue.test.tsx | 56 ++++++++++++------- .../NativeChatInterruptedContinue.tsx | 25 +++++---- .../NativeChatStructuredSession.tsx | 5 +- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 152657522c7b..97c7e034a90a 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -61,7 +61,6 @@ type Props = { journalItems?: readonly AgentJournalRenderItem[] submissions?: readonly Pick[] isWorking?: boolean - onError?: (message: string | null) => void } function Harness(props: Props): React.JSX.Element { @@ -70,19 +69,20 @@ function Harness(props: Props): React.JSX.Element { sessionId: 'session-1', journalItems: props.journalItems ?? cutChat, submissions: props.submissions ?? [], - isWorking: props.isWorking ?? false, - onError: props.onError ?? vi.fn() + isWorking: props.isWorking ?? false }) return ( {continuation.offeredTurnItemId ?? 'none'} {String(continuation.view.continueAvailable)} + {continuation.error ?? 'none'} ) } const continueButton = () => screen.queryByRole('button', { name: 'Continue' }) +const FAILED = "Couldn't continue this chat. Try again, or send a message." beforeEach(() => { mocks.capability = 'supported' @@ -160,35 +160,51 @@ describe('Continue on a reply an Orca stop cut off', () => { it('says so once, in the composer, and offers it again when the host refuses', async () => { mocks.call.mockResolvedValue({ outcome: 'refused', reason: 'agent_session_not_attached' }) - const onError = vi.fn() - render() + render() fireEvent.click(continueButton()!) await waitFor(() => expect(continueButton()).toBeInTheDocument()) + expect(screen.getByTestId('error')).toHaveTextContent(FAILED) + // A retry clears the line until its own answer, which sets the same one line again. fireEvent.click(continueButton()!) + expect(screen.getByTestId('error')).toHaveTextContent('none') await waitFor(() => expect(continueButton()).toBeInTheDocument()) - - // One slot: each click clears it, and the refusal sets the same line again. - expect(onError.mock.calls).toEqual([ - [null], - ["Couldn't continue this chat. Try again, or send a message."], - [null], - ["Couldn't continue this chat. Try again, or send a message."] - ]) + expect(screen.getByTestId('error')).toHaveTextContent(FAILED) }) it('says so and offers it again when the request fails', async () => { mocks.call.mockRejectedValue(new Error('offline')) - const onError = vi.fn() - render() + render() fireEvent.click(continueButton()!) - await waitFor(() => - expect(onError).toHaveBeenLastCalledWith( - "Couldn't continue this chat. Try again, or send a message." - ) - ) + await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent(FAILED)) expect(continueButton()).toBeInTheDocument() }) + + it('drops the line once the chat was continued, here or by another client', async () => { + // The answer was lost after the host accepted it. + mocks.call.mockRejectedValue(new Error('timed out')) + const { rerender } = render() + fireEvent.click(continueButton()!) + await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent(FAILED)) + + // The journal then shows the continuation on its way: the chat is no longer on that cut. + rerender() + + expect(screen.getByTestId('error')).toHaveTextContent('none') + }) + + it('leaves no line when a retry finds the chat already continued', async () => { + mocks.call.mockRejectedValueOnce(new Error('timed out')) + mocks.call.mockResolvedValueOnce({ outcome: 'superseded' }) + render() + fireEvent.click(continueButton()!) + await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent(FAILED)) + + fireEvent.click(continueButton()!) + + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect(screen.getByTestId('error')).toHaveTextContent('none') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index 0c27ce9b1d04..5ccb26899134 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -23,6 +23,9 @@ export type NativeChatInterruptedContinuation = { view: NativeChatOrcaStopView /** The cut turn Continue is offered on right now, if any. */ offeredTurnItemId: string | null + /** The composer's line for a Continue that did not go through, while the chat still sits on + * that cut: gone once it is continued, from here or anywhere else. */ + error: string | null continueNow: () => void } @@ -37,9 +40,8 @@ export function useNativeChatInterruptedContinuation(input: { journalItems: readonly AgentJournalRenderItem[] submissions: readonly Pick[] isWorking: boolean - onError: (message: string | null) => void }): NativeChatInterruptedContinuation { - const { target, sessionId, onError } = input + const { target, sessionId } = input const hostLabel = useStructuredAgentSessionHostLabel(target) const capability = useStructuredAgentSessionHostCapabilityState( target, @@ -53,6 +55,7 @@ export function useNativeChatInterruptedContinuation(input: { ) // The cut this client already asked to continue: hidden until the journal shows what came of it. const [asked, setAsked] = useState(null) + const [failedOn, setFailedOn] = useState(null) const offered = capability === 'supported' && cut && !input.isWorking && !resuming && asked !== cut.turnItemId ? cut.turnItemId @@ -65,15 +68,10 @@ export function useNativeChatInterruptedContinuation(input: { // Nothing was accepted: one line in the composer, which a retry replaces rather than repeats. const failed = (): void => { setAsked((current) => (current === turnItemId ? null : current)) - onError( - translate( - 'components.native-chat.interruptedContinue.failed', - "Couldn't continue this chat. Try again, or send a message." - ) - ) + setFailedOn(turnItemId) } setAsked(turnItemId) - onError(null) + setFailedOn(null) void callStructuredAgentSession(target, 'agentSession.continueInterrupted', { sessionId, turnItemId @@ -83,7 +81,14 @@ export function useNativeChatInterruptedContinuation(input: { const continueAvailable = capability !== 'unsupported' // One object per change, so the chat's rows re-render only when what they show changes. const view = useMemo(() => ({ hostLabel, continueAvailable }), [hostLabel, continueAvailable]) - return { view, offeredTurnItemId: offered, continueNow } + const error = + failedOn !== null && cut?.turnItemId === failedOn + ? translate( + 'components.native-chat.interruptedContinue.failed', + "Couldn't continue this chat. Try again, or send a message." + ) + : null + return { view, offeredTurnItemId: offered, error, continueNow } } export function NativeChatInterruptedContinue({ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index f26c12185802..4fc51e4894c2 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -123,8 +123,7 @@ export function NativeChatStructuredSession( sessionId: props.sessionId, journalItems: controller.journalItems, submissions: controller.submissions, - isWorking: controller.isWorking, - onError: setComposerError + isWorking: controller.isWorking }) const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ outbox: controller.outbox, @@ -323,7 +322,7 @@ export function NativeChatStructuredSession( : readFailure.text } reconnecting={viewState.kind !== 'error' && readFailure !== null && !readFailure.named} - composerError={composerError} + composerError={composerError ?? continuation.error} isVisible={props.isVisible} backgroundTasks={controller.backgroundTasks} stopBackgroundTask={controller.stopBackgroundTask} From 1e83a16c369f2284a428a2c70e53fdec589a0404 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:16:47 -0700 Subject: [PATCH 15/29] fix(native-chat): Continue waits while an opted-in launch may still resume the chat With "resume automatically" on, Continue showed on a quit or update cut while the launch was still waiting for its settings and reading the restart offer, then vanished when the launch's own resume began; a click in between sent a competing continuation. The launch's one decision (nothing offered, ask, or resume) is now published, and the chats it resumes are named the moment it decides, with no gap. Until it decides, and while the setting has not loaded or is on, Continue stays hidden on this machine's chats; a paired server's chats are not the launch's to resume and keep it. --- ...ative-chat-launch-resume-decision.test.tsx | 110 ++++++++++++++++++ .../native-chat-launch-resume-decision.ts | 43 +++++++ .../native-chat-resume-on-restart-store.ts | 8 +- .../NativeChatInterruptedContinue.test.tsx | 25 +++- .../NativeChatInterruptedContinue.tsx | 7 +- 5 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/native-chat-launch-resume-decision.test.tsx create mode 100644 src/renderer/src/components/native-chat-launch-resume-decision.ts diff --git a/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx b/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx new file mode 100644 index 000000000000..51ef0de7a6da --- /dev/null +++ b/src/renderer/src/components/native-chat-launch-resume-decision.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom +// Until the launch decides whether it resumes this machine's chats itself, nothing else offers to +// carry one on; from its decision on, the chats it resumes are named, with no moment in between. + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '../store' +import { getDefaultSettings } from '../../../shared/constants' +import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + _resetNativeChatRestartOffer, + useNativeChatRestartOffer, + useNativeChatRestartResuming +} from './native-chat-resume-on-restart-store' +import { + _resetNativeChatLaunchResumeDecision, + useNativeChatLaunchResumePending +} from './native-chat-launch-resume-decision' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc, + subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +globalThis.IS_REACT_ACT_ENVIRONMENT = true +let root: Root +let container: HTMLDivElement +const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ + sessionId, + workspaceId: 'workspace', + agent: 'codex', + trigger: 'quit', + latestPrompt: `Prompt ${sessionId}`, + recordedAt: 1_800_000_000_000, + executionHostId: 'local', + workspaceKind: 'git-worktree' +})) + +/** What Continue reads, rendered each time it changes. */ +const seen: { pending: boolean; resuming: readonly string[] }[] = [] +function Probe({ launch }: { launch: boolean }): null { + useNativeChatRestartOffer(launch) + seen.push({ + pending: useNativeChatLaunchResumePending(), + resuming: useNativeChatRestartResuming() + }) + return null +} + +function settings(autoResume: boolean | undefined): void { + useAppStore.setState({ + settings: { + ...getDefaultSettings(''), + experimentalStructuredNativeChat: true, + ...(autoResume === undefined ? {} : { nativeChatResumeWorkOnRestart: autoResume }) + } + }) +} + +beforeEach(() => { + rpc.mockReset() + _resetNativeChatRestartOffer() + _resetNativeChatLaunchResumeDecision() + useAppStore.setState(useAppStore.getInitialState(), true) + seen.length = 0 + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() +}) + +it('holds Continue back from the settings wait until the opted-in launch names its chats', async () => { + const read = Promise.withResolvers() + rpc.mockImplementation((_target, method) => + method === 'agentSession.restartResumable' ? read.promise : new Promise(() => {}) + ) + // Settings not loaded yet: the launch may still resume. + await act(async () => root.render()) + expect(seen.at(-1)).toEqual({ pending: true, resuming: [] }) + + settings(true) + await act(async () => root.render()) + expect(seen.at(-1)).toEqual({ pending: true, resuming: [] }) + + await act(async () => read.resolve({ sessions: offered })) + + expect(seen.at(-1)).toEqual({ pending: false, resuming: ['a', 'b'] }) + // Never a render where the launch had decided and its chats were not yet named. + expect(seen.some((entry) => !entry.pending && entry.resuming.length === 0)).toBe(false) +}) + +it('holds nothing back once the launch found nothing to resume', async () => { + settings(true) + rpc.mockResolvedValue({ sessions: [] }) + await act(async () => root.render()) + expect(seen.at(-1)).toEqual({ pending: false, resuming: [] }) +}) + +it('holds nothing back when the launch asks instead of resuming', () => { + settings(false) + act(() => root.render()) + expect(seen.at(-1)).toEqual({ pending: false, resuming: [] }) +}) diff --git a/src/renderer/src/components/native-chat-launch-resume-decision.ts b/src/renderer/src/components/native-chat-launch-resume-decision.ts new file mode 100644 index 000000000000..c5f6d49af55a --- /dev/null +++ b/src/renderer/src/components/native-chat-launch-resume-decision.ts @@ -0,0 +1,43 @@ +// Whether this launch has made its one resume decision: nothing offered, ask, or resume (from then +// on the store's `resuming` names the chats). Until then an opted-in launch may still resume any of +// this machine's chats, so nothing else offers to carry one on. + +import { useSyncExternalStore } from 'react' +import { useAppStore } from '../store' + +let decided = false +const listeners = new Set<() => void>() + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function getDecided(): boolean { + return decided +} + +export function markNativeChatLaunchResumeDecided(): void { + if (!decided) { + decided = true + for (const listener of listeners) { + listener() + } + } +} + +/** Whether this launch may still resume this machine's chats on its own: "resume automatically" is + * on, or its setting has not loaded yet, and the launch has not decided. */ +export function useNativeChatLaunchResumePending(): boolean { + const launchDecided = useSyncExternalStore(subscribe, getDecided, getDecided) + const autoResume = useAppStore((state) => state.settings?.nativeChatResumeWorkOnRestart) + return !launchDecided && autoResume !== false +} + +/** @internal - tests need a clean module between cases. */ +export function _resetNativeChatLaunchResumeDecision(): void { + decided = false + listeners.clear() +} diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index 452aa78cc816..8c5785ac8ca6 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -6,6 +6,7 @@ import { } from '@/runtime/structured-agent-session-status-feed' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { useAppStore } from '../store' +import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision' import { announceRestartDismissUnconfirmed, announceRestartResults, @@ -381,7 +382,10 @@ async function loadLaunchOffer(): Promise { requestNativeChatResumeOnRestartDialog() return } - await continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered)) + // Its batch names the chats before the first await, so `resuming` takes over with no gap. + const continuing = continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered)) + markNativeChatLaunchResumeDecided() + await continuing } /** @@ -394,7 +398,7 @@ export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOf useEffect(() => { if (enabled) { // Fetched after mount, never awaited by startup: the workspace is usable first. - launch ??= loadLaunchOffer() + launch ??= loadLaunchOffer().finally(markNativeChatLaunchResumeDecided) } }, [enabled]) return useSyncExternalStore(subscribe, getNativeChatRestartOffer, getNativeChatRestartOffer) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 97c7e034a90a..9c3d817bbf74 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -7,7 +7,13 @@ const mocks = vi.hoisted(() => { const hostLabel = (): string | null => 'studio-mac' const capability = (): 'unknown' | 'supported' | 'unsupported' => 'supported' const resuming: readonly string[] = [] - return { call: vi.fn(), capability: capability(), hostLabel: hostLabel(), resuming } + return { + call: vi.fn(), + capability: capability(), + hostLabel: hostLabel(), + resuming, + launchPending: false + } }) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) @@ -20,6 +26,9 @@ vi.mock('@/runtime/structured-agent-session-host-capability', () => ({ vi.mock('../native-chat-resume-on-restart-store', () => ({ useNativeChatRestartResuming: () => mocks.resuming })) +vi.mock('../native-chat-launch-resume-decision', () => ({ + useNativeChatLaunchResumePending: () => mocks.launchPending +})) vi.mock('./use-structured-agent-session-host-label', () => ({ useStructuredAgentSessionHostLabel: () => mocks.hostLabel })) @@ -61,11 +70,12 @@ type Props = { journalItems?: readonly AgentJournalRenderItem[] submissions?: readonly Pick[] isWorking?: boolean + target?: RuntimeClientTarget } function Harness(props: Props): React.JSX.Element { const continuation = useNativeChatInterruptedContinuation({ - target: PAIRED, + target: props.target ?? PAIRED, sessionId: 'session-1', journalItems: props.journalItems ?? cutChat, submissions: props.submissions ?? [], @@ -87,6 +97,7 @@ const FAILED = "Couldn't continue this chat. Try again, or send a message." beforeEach(() => { mocks.capability = 'supported' mocks.resuming = [] + mocks.launchPending = false mocks.hostLabel = 'studio-mac' mocks.call.mockReset() }) @@ -145,6 +156,16 @@ describe('Continue on a reply an Orca stop cut off', () => { expect(continueButton()).toBeNull() }) + it("is not offered on this machine's chats while the launch may still resume them", () => { + mocks.launchPending = true + render() + expect(continueButton()).toBeNull() + cleanup() + // The launch resumes only this machine's chats; a paired server's chat is its own. + render() + expect(continueButton()).toBeInTheDocument() + }) + it('is not offered once anything was sent, or while the agent works', () => { render() expect(continueButton()).toBeNull() diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index 5ccb26899134..5bf7099dca4c 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -15,6 +15,7 @@ import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../../ import type { NativeChatOrcaStopView } from './native-chat-orca-stop-context' import { useStructuredAgentSessionHostLabel } from './use-structured-agent-session-host-label' import { useNativeChatRestartResuming } from '../native-chat-resume-on-restart-store' +import { useNativeChatLaunchResumePending } from '../native-chat-launch-resume-decision' type ContinueAnswer = { outcome?: string } @@ -47,8 +48,10 @@ export function useNativeChatInterruptedContinuation(input: { target, AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY ) - // The restart prompt or the launch's own resume is already carrying this chat on. - const resuming = useNativeChatRestartResuming().includes(sessionId) + // The restart prompt or the launch's own resume is carrying this chat on, or the launch may still + // decide to; it resumes only this machine's chats. + const launchPending = useNativeChatLaunchResumePending() && target.kind === 'local' + const resuming = useNativeChatRestartResuming().includes(sessionId) || launchPending const cut = useMemo( () => latestNativeChatOrcaStopCut(input.journalItems, input.submissions), [input.journalItems, input.submissions] From 658fe701dd48c6d2078a0b2760ccaf31d26100a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:21:02 -0700 Subject: [PATCH 16/29] fix(native-chat): a runtime's end survives a late reinstall, a failed write and any clean exit Three ways the runtime record could still read a graceful stop as a crash: - A chat host reinstalled during the quit (a request landing after teardown began) recorded the runtime's start again and erased the end it had just written. A second start of the same runtime now keeps that end. - When the end could not be written (a full disk), the start alone stayed and read as a crash. The runtime now removes its record, so its chats name no cause. - Each `app.exit(0)` had to remember to record the end. A process 'exit' with code 0 now records a quit when nothing else did: Electron emits it on every quit and exit once its loop runs (`app.exit` -> Browser::Shutdown -> the app's 'quit' -> process 'exit'), and Node on every `process.exit`. The relaunch and GPU-fallback calls it covers are dropped; the quit teardown, the headless server's stop, the update watchdog and Windows session end keep theirs, which run earlier or say more. --- src/main/ipc/app.test.ts | 16 +------ src/main/ipc/app.ts | 3 -- .../agent-session-runtime-end-record.test.ts | 44 ++++++++++++++++++- .../agent-session-runtime-end-record.ts | 29 ++++++++++-- src/main/startup/gpu-lifecycle.ts | 4 -- 5 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index b55149864900..07b8b82f9a76 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -12,8 +12,7 @@ const { showOpenDialogMock, trustFloatingWorkspaceDirectoryMock, registerRendererShutdownCheckpointHandlerMock, - registerMacKeyboardLayoutChangeNotificationsMock, - recordAgentSessionRuntimeEndMock + registerMacKeyboardLayoutChangeNotificationsMock } = vi.hoisted(() => ({ handlers: new Map unknown>(), appExitMock: vi.fn(), @@ -25,8 +24,7 @@ const { showOpenDialogMock: vi.fn(), trustFloatingWorkspaceDirectoryMock: vi.fn(), registerRendererShutdownCheckpointHandlerMock: vi.fn(), - registerMacKeyboardLayoutChangeNotificationsMock: vi.fn(), - recordAgentSessionRuntimeEndMock: vi.fn() + registerMacKeyboardLayoutChangeNotificationsMock: vi.fn() })) vi.mock('node:child_process', () => ({ @@ -103,10 +101,6 @@ vi.mock('../app-relaunch', () => ({ relaunchApp: relaunchAppMock })) -vi.mock('../runtime/agent-session-runtime-end-record', () => ({ - recordAgentSessionRuntimeEnd: recordAgentSessionRuntimeEndMock -})) - vi.mock('./floating-workspace-directory', () => ({ ensureDefaultFloatingWorkspacePath: vi.fn(), trustFloatingWorkspaceDirectory: trustFloatingWorkspaceDirectoryMock, @@ -164,7 +158,6 @@ describe('registerAppHandlers', () => { trustFloatingWorkspaceDirectoryMock.mockReset() registerRendererShutdownCheckpointHandlerMock.mockReset() registerMacKeyboardLayoutChangeNotificationsMock.mockReset() - recordAgentSessionRuntimeEndMock.mockReset() for (const probe of Object.values(windowsProbes)) { probe.mockClear() } @@ -207,11 +200,6 @@ describe('registerAppHandlers', () => { expect(destroySystemTrayMock.mock.invocationCallOrder[0]).toBeLessThan( appExitMock.mock.invocationCallOrder[0] ) - // app.exit skips will-quit, so the relaunch records the runtime's end itself. - expect(recordAgentSessionRuntimeEndMock).toHaveBeenCalledExactlyOnceWith('quit') - expect(recordAgentSessionRuntimeEndMock.mock.invocationCallOrder[0]).toBeLessThan( - appExitMock.mock.invocationCallOrder[0] - ) }) it('waits for pre-relaunch cleanup before exiting', async () => { diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index e70c0e00ed3b..d056727fdb71 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -25,7 +25,6 @@ import { registerMacSymbolicHotkeysProbeHandler } from './macos-symbolic-hotkeys import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint' import { readMacKeyboardLayoutSnapshot } from './macos-keyboard-layout-snapshot' import { registerMacKeyboardLayoutChangeNotifications } from './macos-keyboard-layout-change-notifications' -import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record' const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox' @@ -303,8 +302,6 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp setTimeout(() => { // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. destroySystemTray() - // app.exit skips will-quit, whose teardown would otherwise record this. - recordAgentSessionRuntimeEnd('quit') relaunchApp('renderer-request') app.exit(0) }, 150) diff --git a/src/main/runtime/agent-session-runtime-end-record.test.ts b/src/main/runtime/agent-session-runtime-end-record.test.ts index 6370fb930124..7121f5098b6f 100644 --- a/src/main/runtime/agent-session-runtime-end-record.test.ts +++ b/src/main/runtime/agent-session-runtime-end-record.test.ts @@ -6,10 +6,27 @@ import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DurableFileWrite from '../durable-file-write' + +const writes = vi.hoisted(() => ({ fail: false })) +vi.mock('../durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileDurableSync: (...args: Parameters) => { + if (writes.fail) { + throw new Error('ENOSPC: no space left on device') + } + actual.writeFileDurableSync(...args) + } + } +}) + import { beginAgentSessionRuntimeRecord, readAgentSessionRuntimeEnds, - recordAgentSessionRuntimeEnd + recordAgentSessionRuntimeEnd, + recordAgentSessionRuntimeEndOnExit } from './agent-session-runtime-end-record' import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown' @@ -19,6 +36,7 @@ const RUNTIME_B = '0000000b-0000-4000-8000-000000000000' let directory: string beforeEach(async () => { + writes.fail = false directory = await mkdtemp(join(tmpdir(), 'orca-runtime-end-')) }) @@ -104,6 +122,30 @@ describe('what a runtime records about itself', () => { expect(() => recordAgentSessionRuntimeEnd('quit', 2)).not.toThrow() }) + it('keeps its end when the same runtime records its start again during the quit', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + recordAgentSessionRuntimeEnd('update', 2) + // A host reinstalled by a request that landed during the quit opens its store again. + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 3) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('update') + }) + + it('names no cause, rather than a crash, when its end could not be written', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + writes.fail = true + recordAgentSessionRuntimeEnd('quit', 2) + expect(readAgentSessionRuntimeEnds(directory)?.has(RUNTIME_A)).toBe(false) + }) + + it('reads a clean process exit nothing else recorded as a quit, and any other exit as nothing', () => { + beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) + expect(process.listeners('exit')).toContain(recordAgentSessionRuntimeEndOnExit) + recordAgentSessionRuntimeEndOnExit(1) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('crash') + recordAgentSessionRuntimeEndOnExit(0) + expect(readAgentSessionRuntimeEnds(directory)?.get(RUNTIME_A)).toBe('quit') + }) + it('leaves no temp files behind', async () => { beginAgentSessionRuntimeRecord(directory, RUNTIME_A, 1) recordAgentSessionRuntimeEnd('quit', 2) diff --git a/src/main/runtime/agent-session-runtime-end-record.ts b/src/main/runtime/agent-session-runtime-end-record.ts index cc6bc67a7662..c57c593b4dc1 100644 --- a/src/main/runtime/agent-session-runtime-end-record.ts +++ b/src/main/runtime/agent-session-runtime-end-record.ts @@ -56,7 +56,19 @@ function prune(directory: string, keep: string): void { } } -/** Records that this runtime started. Never throws: a runtime with no record reads as unknown. */ +/** A graceful exit no call site recorded, such as an `app.exit(0)` or `process.exit(0)`, is a quit: + * Electron emits the process's 'exit' on every quit or exit once its loop runs, and Node on every + * `process.exit`. A non-zero exit records nothing, so it stays a crash. */ +export function recordAgentSessionRuntimeEndOnExit(code: number): void { + if (code === 0) { + recordAgentSessionRuntimeEnd('quit') + } +} + +let exitHookInstalled = false + +/** Records that this runtime started. Never throws: a runtime with no record reads as unknown. A + * second start of the same runtime (a host reinstalled during its quit) keeps the end it recorded. */ export function beginAgentSessionRuntimeRecord( stateDirectory: string, runtime: string, @@ -64,7 +76,13 @@ export function beginAgentSessionRuntimeRecord( ): void { const directory = join(stateDirectory, RUNTIMES_DIRECTORY) const name = `${runtime}.json` - current = { path: join(directory, name), record: { runtime, startedAt: now } } + const path = join(directory, name) + const ended = current?.path === path && current.record.end ? current.record : null + current = { path, record: ended ?? { runtime, startedAt: now } } + if (!exitHookInstalled) { + exitHookInstalled = true + process.once('exit', recordAgentSessionRuntimeEndOnExit) + } try { mkdirSync(directory, { recursive: true }) writeRecord(current.path, current.record) @@ -89,7 +107,12 @@ export function recordAgentSessionRuntimeEnd( try { writeRecord(current.path, current.record) } catch { - // Unwritten, a death of this runtime's owners reads as a crash. + // The start alone would read as a crash; with no record its owners name no cause. + try { + rmSync(current.path, { force: true }) + } catch { + // Nothing left to try: the start stays, and reads as a crash. + } } } diff --git a/src/main/startup/gpu-lifecycle.ts b/src/main/startup/gpu-lifecycle.ts index 26dc468c68c5..89055aa2ba3e 100644 --- a/src/main/startup/gpu-lifecycle.ts +++ b/src/main/startup/gpu-lifecycle.ts @@ -24,7 +24,6 @@ import { } from './windows-install-dir-acl-recovery' import { mainProcessState as state, gpuFallbackEnvironment } from './main-process-state' import { createGpuAccelerationAboutPanelOptions } from '../menu/gpu-acceleration-about-panel' -import { recordAgentSessionRuntimeEnd } from '../runtime/agent-session-runtime-end-record' export function updateGpuAccelerationAboutPanel(): void { app.setAboutPanelOptions( @@ -128,8 +127,6 @@ export async function presentGpuFallbackRecoveredLaunchPrompt( crashesInWindow: marker.crashesInWindow }) destroySystemTray() - // app.exit skips will-quit, whose teardown would otherwise record this. - recordAgentSessionRuntimeEnd('quit') app.exit(0) } }) @@ -258,7 +255,6 @@ export async function handleGpuChildCrash( state.isQuitting = true relaunchApp('gpu-fallback', fallbackData) destroySystemTray() - recordAgentSessionRuntimeEnd('quit') app.exit(0) } } From 82ab348f630603fe0afaa040acf81f03cf95fcd7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:24:09 -0700 Subject: [PATCH 17/29] test(native-chat): build the re-presented row as the plain status item it is --- .../components/native-chat/NativeChatNoticeRow.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index 4f435c4d1b43..f34b85de39c5 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -78,10 +78,13 @@ describe('the row an Orca stop leaves', () => { // 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 = { - ...orcaStopRow('crash'), + kind: 'status', text: 'This response was interrupted. You can continue in this conversation.', - tone: 'notice' + tone: 'notice', + presentation: 'orca-stop', + orcaStop: { cause: 'crash' } } renderStatus(represented, 'studio-mac', true) expect( From 9795b3a3168155eaf41dd07a1bf4a3e7d464b3c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:52:56 -0700 Subject: [PATCH 18/29] fix(native-chat): a Continue click clears the composer's old error, so its own failure shows Since the "Couldn't continue" line became derived, an older composer error (such as "Remove attachments before using a chat-session command.") outranked it: a failed Continue showed the old error instead, and a Continue that went through left the old error on screen. A Continue click is the user's newer action, so it clears the composer's error again, as before; the line then shows the Continue's own failure, if any. That failure still goes away by itself once the chat is continued, and nothing but the user's own Continue click clears an unrelated composer error. --- .../NativeChatInterruptedContinue.test.tsx | 35 ++++++++++++++++++- .../NativeChatInterruptedContinue.tsx | 18 +++++++--- .../NativeChatStructuredSession.tsx | 5 +-- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx index 9c3d817bbf74..3badc70377bd 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useState } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { @@ -74,7 +75,10 @@ type Props = { } function Harness(props: Props): React.JSX.Element { + // The chat's own composer error, as NativeChatStructuredSession holds it. + const [composerError, setComposerError] = useState(null) const continuation = useNativeChatInterruptedContinuation({ + composer: { error: composerError, clearError: () => setComposerError(null) }, target: props.target ?? PAIRED, sessionId: 'session-1', journalItems: props.journalItems ?? cutChat, @@ -85,7 +89,10 @@ function Harness(props: Props): React.JSX.Element { {continuation.offeredTurnItemId ?? 'none'} {String(continuation.view.continueAvailable)} - {continuation.error ?? 'none'} + {continuation.composerError ?? 'none'} + ) @@ -93,6 +100,7 @@ function Harness(props: Props): React.JSX.Element { const continueButton = () => screen.queryByRole('button', { name: 'Continue' }) const FAILED = "Couldn't continue this chat. Try again, or send a message." +const ATTACHMENTS = 'Remove attachments before using a chat-session command.' beforeEach(() => { mocks.capability = 'supported' @@ -203,6 +211,31 @@ describe('Continue on a reply an Orca stop cut off', () => { expect(continueButton()).toBeInTheDocument() }) + it('replaces an older composer error with its own failure, and clears it when it goes through', async () => { + mocks.call.mockResolvedValueOnce({ outcome: 'refused', reason: 'agent_session_conflict' }) + mocks.call.mockResolvedValueOnce({ outcome: 'pending' }) + render() + fireEvent.click(screen.getByRole('button', { name: 'compose' })) + expect(screen.getByTestId('error')).toHaveTextContent(ATTACHMENTS) + + fireEvent.click(continueButton()!) + await waitFor(() => expect(continueButton()).toBeInTheDocument()) + expect(screen.getByTestId('error')).toHaveTextContent(FAILED) + + fireEvent.click(screen.getByRole('button', { name: 'compose' })) + expect(screen.getByTestId('error')).toHaveTextContent(ATTACHMENTS) + fireEvent.click(continueButton()!) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect(screen.getByTestId('error')).toHaveTextContent('none') + }) + + it('leaves an unrelated composer error alone when the chat moves on by itself', () => { + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'compose' })) + rerender() + expect(screen.getByTestId('error')).toHaveTextContent(ATTACHMENTS) + }) + it('drops the line once the chat was continued, here or by another client', async () => { // The answer was lost after the host accepted it. mocks.call.mockRejectedValue(new Error('timed out')) diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index 5bf7099dca4c..76f8e283ff88 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -24,9 +24,9 @@ export type NativeChatInterruptedContinuation = { view: NativeChatOrcaStopView /** The cut turn Continue is offered on right now, if any. */ offeredTurnItemId: string | null - /** The composer's line for a Continue that did not go through, while the chat still sits on - * that cut: gone once it is continued, from here or anywhere else. */ - error: string | null + /** The composer's error line: the composer's own, or else a Continue that did not go through, + * shown while the chat still sits on that cut and gone once it is continued from anywhere. */ + composerError: string | null continueNow: () => void } @@ -41,6 +41,8 @@ export function useNativeChatInterruptedContinuation(input: { journalItems: readonly AgentJournalRenderItem[] submissions: readonly Pick[] isWorking: boolean + /** The composer's own error; a Continue click is the user's newer action, so it clears it. */ + composer: { error: string | null; clearError: () => void } }): NativeChatInterruptedContinuation { const { target, sessionId } = input const hostLabel = useStructuredAgentSessionHostLabel(target) @@ -75,6 +77,7 @@ export function useNativeChatInterruptedContinuation(input: { } setAsked(turnItemId) setFailedOn(null) + input.composer.clearError() void callStructuredAgentSession(target, 'agentSession.continueInterrupted', { sessionId, turnItemId @@ -84,14 +87,19 @@ export function useNativeChatInterruptedContinuation(input: { const continueAvailable = capability !== 'unsupported' // One object per change, so the chat's rows re-render only when what they show changes. const view = useMemo(() => ({ hostLabel, continueAvailable }), [hostLabel, continueAvailable]) - const error = + const failedHere = failedOn !== null && cut?.turnItemId === failedOn ? translate( 'components.native-chat.interruptedContinue.failed', "Couldn't continue this chat. Try again, or send a message." ) : null - return { view, offeredTurnItemId: offered, error, continueNow } + return { + view, + offeredTurnItemId: offered, + composerError: input.composer.error ?? failedHere, + continueNow + } } export function NativeChatInterruptedContinue({ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 4fc51e4894c2..972c9c3bf3fd 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -123,7 +123,8 @@ export function NativeChatStructuredSession( sessionId: props.sessionId, journalItems: controller.journalItems, submissions: controller.submissions, - isWorking: controller.isWorking + isWorking: controller.isWorking, + composer: { error: composerError, clearError: () => setComposerError(null) } }) const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ outbox: controller.outbox, @@ -322,7 +323,7 @@ export function NativeChatStructuredSession( : readFailure.text } reconnecting={viewState.kind !== 'error' && readFailure !== null && !readFailure.named} - composerError={composerError ?? continuation.error} + composerError={continuation.composerError} isVisible={props.isVisible} backgroundTasks={controller.backgroundTasks} stopBackgroundTask={controller.stopBackgroundTask} From 7b8528ffb3aa7024a072b2d69d6d7277cebeea50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:06:49 -0700 Subject: [PATCH 19/29] fix(native-chat): a chat start compares the owner process, not the runtime stamped on it A chat start checks that the process it just started is the one the record names, by a deep comparison of the stored owner with the adapter's process. The store stamps that owner with the Orca runtime holding it, so the check passed only because the store happened to return the record from before the stamp; returning the published record would have refused every chat start with agent_session_ownership_unknown. The start now compares the process identity without the runtime stamp, which says who holds the process rather than which process it is. --- ...-session-acquisition-runtime-stamp.test.ts | 125 ++++++++++++++++++ .../structured-agent-session-acquisition.ts | 18 ++- 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts new file mode 100644 index 000000000000..599b5a65a111 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts @@ -0,0 +1,125 @@ +// A chat starts whatever the store stamped on its owner: the Orca runtime holding the process is +// recorded beside its identity, and the start compares the process, not the stamp. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { agentSessionRuntimeIncarnation } from '../../runtime/agent-session-runtime-attribution' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' +import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +const NOW = 1_800_000_000_000 +const SESSION = 'stamped-owner-session' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function attachParams(): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +it('starts a chat whose recorded owner carries the runtime stamp', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stamped-owner-')) + const store = await openTestAgentSessionRecordStore(root) + // The record as the store published it, stamp included, rather than the transition's own copy. + const commit = store.commitProcessIdentity.bind(store) + vi.spyOn(store, 'commitProcessIdentity').mockImplementation(async (input) => { + await commit(input) + const published = store.getRecord(input.sessionId) + if (!published) { + throw new Error('the committed record is missing') + } + return published + }) + const adapter: StructuredAgentSessionAdapter = { + acquire: vi.fn( + async ({ fence, spawnToken, onSpawned }) => { + const process = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken } + await onSpawned?.(process) + return { + process, + link: { + linkId: 'created-link', + handle: codexProviderHandle('thread-1'), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + } + ), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } + + const attached = await performAttach({ + logger: createStructuredAgentSessionLogger(), + store, + adapter, + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + }) + + expect(attached).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease.ownerProcess).toMatchObject({ + pid: 4242, + runtime: agentSessionRuntimeIncarnation() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 6d6589521df7..e80af5a0c0e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,5 +1,8 @@ import { isDeepStrictEqual } from 'node:util' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../../shared/agent-session-record' import { AgentSessionPreSpawnError, isAgentSessionPreSpawnError, @@ -11,6 +14,17 @@ import type { AttachFlowInput } from './structured-agent-session-attach-flow' import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation' +/** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp + * is about who holds the process, not which process it is. */ +function sameOwnerProcess( + stored: AgentSessionProcessIdentity, + acquired: AgentSessionProcessIdentity +): boolean { + const { runtime: _storedRuntime, ...storedProcess } = stored + const { runtime: _acquiredRuntime, ...acquiredProcess } = acquired + return isDeepStrictEqual(storedProcess, acquiredProcess) +} + /** A reservation with no process behind it is only a promise to spawn; the * adapter makes it real and the store then grants the writer. */ export async function acquireOwner( @@ -70,7 +84,7 @@ export async function acquireOwner( process: acquired.process, now: input.now() }) - } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { + } else if (!sameOwnerProcess(record.lease.ownerProcess, acquired.process)) { throw new Error('agent_session_ownership_unknown') } const proved = await input.store.proveOwner({ From 26e614c4052d6cd624295945e64f5eb83f29ce7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:56:35 -0700 Subject: [PATCH 20/29] test(native-chat): count agentSession.continueInterrupted among the structured methods --- src/main/runtime/rpc/methods/structured-agent-session.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 82437bb6d81b..1694a4521885 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -174,7 +174,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(32) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(33) }) it('hides the surface from a declared client that did not advertise it', async () => { From 624fe5375a9977d6b14d7064f972a4539e81fdec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:10:05 -0700 Subject: [PATCH 21/29] refactor(native-chat): derive the structured chat's transcript session in its own hook Main's appearance work and this branch's Continue wiring together put NativeChatStructuredSession past the 400-line limit for components. The session the transcript reads moves, unchanged, to use-structured-chat-live-session.ts. --- .../NativeChatStructuredSession.tsx | 35 ++---------- .../use-structured-chat-live-session.ts | 55 +++++++++++++++++++ 2 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-chat-live-session.ts diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b1d316aa48ea..cbb8efdbfd28 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -7,7 +7,7 @@ import { useMemo, useRef, useState } from 'react' import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' -import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { useStructuredChatLiveSession } from './use-structured-chat-live-session' import { NativeChatApprovalCard } from './NativeChatApprovalCard' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' @@ -95,34 +95,11 @@ export function NativeChatStructuredSession( target: props.target }) const historyPhase = structuredChatHistoryPhase(provisionalLaunch, controller.status) - const session = useMemo( - () => ({ - messages: controller.messages, - status: - controller.status === 'error' - ? 'error' - : historyPhase !== 'known' - ? 'loading' - : controller.isWorking - ? 'working' - : controller.messages.length === 0 - ? 'empty' - : 'ready', - sessionId: props.sessionId, - agent: props.agent, - ...(controller.error ? { error: controller.error } : {}), - hasMore: controller.hasOlder, - loadingEarlier: controller.loadingOlder, - olderHistoryGeneration: controller.olderHistoryGeneration, - loadEarlier: controller.loadOlder, - readPhase: - controller.status === 'loading' - ? 'loading' - : controller.status === 'error' - ? 'error' - : 'ready' - }), - [controller, historyPhase, props.agent, props.sessionId] + const session = useStructuredChatLiveSession( + controller, + historyPhase, + props.sessionId, + props.agent ) const agentLabel = structuredAgentLabel(props.agent) const continuation = useNativeChatInterruptedContinuation({ diff --git a/src/renderer/src/components/native-chat/use-structured-chat-live-session.ts b/src/renderer/src/components/native-chat/use-structured-chat-live-session.ts new file mode 100644 index 000000000000..1f9137107a79 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-chat-live-session.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import type { useStructuredAgentSession } from './use-structured-agent-session' +import type { structuredChatHistoryPhase } from './native-chat-view-state' + +type StructuredChatController = Pick< + ReturnType, + | 'messages' + | 'status' + | 'isWorking' + | 'error' + | 'hasOlder' + | 'loadingOlder' + | 'olderHistoryGeneration' + | 'loadOlder' +> + +/** The structured chat as the shared transcript reads it: its rows, and a status that stays + * `loading` until the chat's history is known. */ +export function useStructuredChatLiveSession( + controller: StructuredChatController, + historyPhase: ReturnType, + sessionId: string, + agent: NativeChatLiveSession['agent'] +): NativeChatLiveSession { + return useMemo( + () => ({ + messages: controller.messages, + status: + controller.status === 'error' + ? 'error' + : historyPhase !== 'known' + ? 'loading' + : controller.isWorking + ? 'working' + : controller.messages.length === 0 + ? 'empty' + : 'ready', + sessionId, + agent, + ...(controller.error ? { error: controller.error } : {}), + hasMore: controller.hasOlder, + loadingEarlier: controller.loadingOlder, + olderHistoryGeneration: controller.olderHistoryGeneration, + loadEarlier: controller.loadOlder, + readPhase: + controller.status === 'loading' + ? 'loading' + : controller.status === 'error' + ? 'error' + : 'ready' + }), + [controller, historyPhase, agent, sessionId] + ) +} From 0b26c868225e03459c2d15bb30c591b8746f7c75 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:06:35 -0700 Subject: [PATCH 22/29] refactor(native-chat): keep the Continue capability in its own module Main grew protocol-version.ts to its line limit; the Continue capability moves to its own module, as other capability groups have, and the runtime list still names it. --- .../native-chat/NativeChatInterruptedContinue.tsx | 2 +- .../agent-session-continue-interrupted-capability.ts | 7 +++++++ src/shared/protocol-version.ts | 6 +----- 3 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 src/shared/agent-session-continue-interrupted-capability.ts diff --git a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx index 76f8e283ff88..ef326d51ad74 100644 --- a/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -11,7 +11,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { latestNativeChatOrcaStopCut } from '../../../../shared/native-chat-orca-stop-cut' -import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-continue-interrupted-capability' import type { NativeChatOrcaStopView } from './native-chat-orca-stop-context' import { useStructuredAgentSessionHostLabel } from './use-structured-agent-session-host-label' import { useNativeChatRestartResuming } from '../native-chat-resume-on-restart-store' diff --git a/src/shared/agent-session-continue-interrupted-capability.ts b/src/shared/agent-session-continue-interrupted-capability.ts new file mode 100644 index 000000000000..359e4c5ad12f --- /dev/null +++ b/src/shared/agent-session-continue-interrupted-capability.ts @@ -0,0 +1,7 @@ +// Split out of protocol-version.ts, which lists it in RUNTIME_CAPABILITIES. Import it from here. + +// Why: a client may call `agentSession.continueInterrupted` (Continue on a reply an Orca stop cut +// off) only on a host that has it; an older host also writes no row naming that stop, so a client +// never offers Continue there. +export const AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY = + 'agent-session.continue-interrupted.v1' as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 29f0c8242a79..e985a7bd7344 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,5 +1,6 @@ import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resume-runtime-capabilities' import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability' +import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability' export { AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, @@ -229,11 +230,6 @@ export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = // gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const -// Why: a client may call `agentSession.continueInterrupted` (Continue on a reply an Orca stop cut -// off) only on a host that has it; an older host also writes no row naming that stop, so a client -// never offers Continue there. -export const AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY = - 'agent-session.continue-interrupted.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = From 87e7812f85a980b154c7631dfd209cfe88ae7c84 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:34:42 -0700 Subject: [PATCH 23/29] refactor(native-chat): keep two shared files within their line limit after the main merge Main left agent-session-record.ts and structured-agent-session-params.ts just under 300 lines, and this branch's additions put them over. The account-home shape check moves next to the account-home type it checks (written without a type assertion), and the Continue params move to their own contract module; the params catalog is regenerated. No behavior change. --- .../structured-agent-session-schemas.ts | 2 +- src/shared/agent-session-account-home.ts | 19 ++++++++++++++++ src/shared/agent-session-record.ts | 22 ++++--------------- .../rpc-params-catalog.generated.ts | 2 +- ...tructured-agent-session-continue-params.ts | 10 +++++++++ .../structured-agent-session-params.ts | 6 ----- 6 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 src/shared/rpc-contract/structured-agent-session-continue-params.ts diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 20f592048217..4bf574cbb7ee 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -6,7 +6,6 @@ export { AgentsParams, AttachParams, CancelParams, - ContinueInterruptedParams, ConversationCommandParams, CreateIntentParams, CreateParams, @@ -32,3 +31,4 @@ export { ThreadGoalParams, UnsubscribeParams } from '../../../../shared/rpc-contract/structured-agent-session-params' +export { ContinueInterruptedParams } from '../../../../shared/rpc-contract/structured-agent-session-continue-params' diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts index f33f70347583..e003c4e0d9ba 100644 --- a/src/shared/agent-session-account-home.ts +++ b/src/shared/agent-session-account-home.ts @@ -24,3 +24,22 @@ export function agentSessionAccountHome( ): AgentSessionAccountHome { return { variable: agent.accountHomeVariable, path } } + +const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ +const MAX_PATH_LENGTH = 4096 + +/** Shape only: whether the variable is the one the record's agent pins is a launch-time question + * (`agentDrivesSession`), so an agent that renames its variable never hides its chats. */ +export function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome { + if (typeof value !== 'object' || value === null || !('variable' in value) || !('path' in value)) { + return false + } + const { variable, path } = value + return ( + typeof variable === 'string' && + ENVIRONMENT_VARIABLE_NAME.test(variable) && + typeof path === 'string' && + path.length > 0 && + path.length <= MAX_PATH_LENGTH + ) +} diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index a59425343402..fd6849a1a78f 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -28,7 +28,10 @@ import { isAgentSessionProviderHandleInNamespace, isStructuredAgentId } from './agent-session-provider-handle-encoding' -import type { AgentSessionAccountHome } from './agent-session-account-home' +import { + isAgentSessionAccountHome, + type AgentSessionAccountHome +} from './agent-session-account-home' export type { AgentSessionAccountHome } from './agent-session-account-home' @@ -167,7 +170,6 @@ export type AgentSessionOptionsReplacement = { const MAX_ID_LENGTH = 512 /** A death evidence's `detail` past this fails a load, so whoever writes one cuts it here. */ export const MAX_AGENT_SESSION_DEATH_DETAIL_CHARS = MAX_ID_LENGTH -const MAX_PATH_LENGTH = 4096 const MAX_LAUNCH_ENV_ENTRIES = 256 const MAX_LAUNCH_ENV_VALUE_LENGTH = 65_536 const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/ @@ -237,22 +239,6 @@ export function isAgentSessionProcessIdentity( ) } -const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ - -/** Shape only: whether the variable is the one the record's agent pins is a launch-time question - * (`agentDrivesSession`), so an agent that renames its variable never hides its chats. */ -function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome { - if (typeof value !== 'object' || value === null) { - return false - } - const home = value as Partial - return ( - typeof home.variable === 'string' && - ENVIRONMENT_VARIABLE_NAME.test(home.variable) && - isBoundedString(home.path, MAX_PATH_LENGTH) - ) -} - export function isAgentSessionOptions(value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 38bd69c983f3..ee74a73a1005 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -471,11 +471,11 @@ import { SpeechModelAction } from './speech-params' import { SshTarget } from './ssh-params' +import { ContinueInterruptedParams } from './structured-agent-session-continue-params' import { AgentsParams, AttachParams, CancelParams, - ContinueInterruptedParams, ConversationCommandParams, CreateParams, CreateSupportParams, diff --git a/src/shared/rpc-contract/structured-agent-session-continue-params.ts b/src/shared/rpc-contract/structured-agent-session-continue-params.ts new file mode 100644 index 000000000000..fed0bc550a86 --- /dev/null +++ b/src/shared/rpc-contract/structured-agent-session-continue-params.ts @@ -0,0 +1,10 @@ +// Split out of structured-agent-session-params.ts, which is at its line limit. + +import { z } from 'zod' +import { SessionId } from './structured-agent-session-params' + +/** Continue on the chat's latest turn, which an Orca stop cut off. The host re-checks that this + * turn is still that cut before sending anything, so a stale or second click sends nothing. */ +export const ContinueInterruptedParams = z + .object({ sessionId: SessionId, turnItemId: z.string().min(1).max(1024) }) + .strict() diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 09760bcd8e90..5bedb40d784e 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -360,12 +360,6 @@ export const RestartResumeParams = z .object({ sessionIds: z.array(SessionId).max(MAX_RESTART_RESUME_SESSIONS).optional() }) .strict() -/** Continue on the chat's latest turn, which an Orca stop cut off. The host re-checks that this - * turn is still that cut before sending anything, so a stale or second click sends nothing. */ -export const ContinueInterruptedParams = z - .object({ sessionId: SessionId, turnItemId: z.string().min(1).max(1024) }) - .strict() - export const HistoryParams = z .object({ sessionId: SessionId, From e7ec734118bf3ecc52643e57a3dd43be97fdf1cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:32:12 -0700 Subject: [PATCH 24/29] test(native-chat): compare the store directory's files without depending on listing order The corruption test checks that no file was created or removed by comparing two recursive listings. Their order is the runtime's: with the per-runtime record directory nested under the store, Bun returns the same entries in a different order than Node. Both listings are now sorted. --- .../structured-agent-session-journal-corruption.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index bd7d735abbc9..436fb68c7fec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -51,7 +51,8 @@ const stop = (turnEnvelope = envelope('agentSession.cancel', { turnId: 'turn-1' // T-corrupt-midsession. it('refuses a send as corrupt when SQLite reports damage, and still stops the agent', async () => { await attach() - const files = await readdir(root, { recursive: true }) + // Order-free: recursive listing order is the runtime's, and only what exists matters. + const files = (await readdir(root, { recursive: true })).toSorted() const damaged = sqliteError('database disk image is malformed', 11) vi.spyOn(openTestJournalHostDatabase(root), 'transaction').mockImplementation(() => { throw damaged @@ -80,7 +81,7 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag }) // The restart-offer withdrawal the attach started holds its lock until it ends. await hostTestRecoveryCapsuleSettled() - expect(await readdir(root, { recursive: true })).toEqual(files) + expect((await readdir(root, { recursive: true })).toSorted()).toEqual(files) }) it.each([ From 7948ec1ecebaeaf63fa8312193bbdb0a98cd4995 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:16:33 -0700 Subject: [PATCH 25/29] fix: share the path bound main's launch-directory check needs --- src/shared/agent-session-account-home.ts | 2 +- src/shared/agent-session-record.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts index e003c4e0d9ba..1f899edad821 100644 --- a/src/shared/agent-session-account-home.ts +++ b/src/shared/agent-session-account-home.ts @@ -26,7 +26,7 @@ export function agentSessionAccountHome( } const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/ -const MAX_PATH_LENGTH = 4096 +export const MAX_PATH_LENGTH = 4096 /** Shape only: whether the variable is the one the record's agent pins is a launch-time question * (`agentDrivesSession`), so an agent that renames its variable never hides its chats. */ diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 4f31234a62ff..b8477e7af853 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -30,6 +30,7 @@ import { } from './agent-session-provider-handle-encoding' import { isAgentSessionAccountHome, + MAX_PATH_LENGTH, type AgentSessionAccountHome } from './agent-session-account-home' From abbe6299704a434168c3882ec40e48aae833c2d9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:01:52 -0700 Subject: [PATCH 26/29] test: give the stop-row fold rows the draws flag main's fold now reads --- .../structured-agent-session-orca-stop-row-readers.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts index 0e74bed19354..74b5abe1bc31 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts @@ -78,6 +78,7 @@ describe('the row a host writes for a cut it knows the cause of', () => { 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 From 381c7e32602c69c483d27efd8e0958d422ce8b9c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:12:59 -0700 Subject: [PATCH 27/29] refactor: mark the launch's resume decision where the resume begins --- .../src/components/native-chat-resume-on-restart-store.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index a241275f1dc8..9111eae70dbe 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -295,6 +295,8 @@ export async function continueNativeChatRestartOffer( let outcome: Parameters resumeBatches.add(batch) syncResuming() + // `resuming` now names the chats, so the launch's one resume decision is made. + markNativeChatLaunchResumeDecided() try { const result = await callStructuredAgentSession< HostOfferPayload & { continued?: RestartContinuationOutcome[] } @@ -389,10 +391,7 @@ async function loadLaunchOffer(): Promise { requestNativeChatResumeOnRestartDialog() return } - // Its batch names the chats before the first await, so `resuming` takes over with no gap. - const continuing = continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered)) - markNativeChatLaunchResumeDecided() - await continuing + await continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered)) } /** From e18e074439fdda7c9cbf9c677e0e9b8b741c15bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:29:14 -0700 Subject: [PATCH 28/29] test: count main's new structured method alongside agentSession.continueInterrupted --- src/main/runtime/rpc/methods/structured-agent-session.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index e33c7800cabb..93fff5d92680 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -175,7 +175,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(33) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(34) }) it('hides the surface from a declared client that did not advertise it', async () => { From 806a13611e90070eed632fc059e29635736dbb04 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:28:02 -0700 Subject: [PATCH 29/29] fix: the journal database keeps its folder, where runtime end records live Main's #26038 dropped stateDirectory from JournalHostDatabase; this PR's runtime end records are read from and written beside it. --- .../agent-session-journal/journal-host-database.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index e87de366fff2..aaea0228b6e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -28,14 +28,21 @@ export class JournalHostDatabase { /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false - private constructor(opened: OpenJournalDatabase) { + private constructor( + opened: OpenJournalDatabase, + /** Where the database lives; per-runtime end records sit beside it. */ + readonly stateDirectory: string + ) { this.connection = opened.db this.readOnly = opened.readOnly } static open(stateDirectory: string): JournalHostDatabase { mkdirSync(stateDirectory, { recursive: true }) - return new JournalHostDatabase(openJournalDatabase(journalDatabasePath(stateDirectory))) + return new JournalHostDatabase( + openJournalDatabase(journalDatabasePath(stateDirectory)), + stateDirectory + ) } get isClosed(): boolean {