diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index fc93f232acb..1edfaacebc7 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -44,6 +44,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts', @@ -121,6 +122,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts', @@ -158,6 +160,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-record-store-tab-table.test.ts', 'src/main/runtime/agent-session-record-store.test.ts', 'src/main/runtime/agent-session-released-reservation-replay.test.ts', + 'src/main/runtime/agent-session-runtime-attribution.test.ts', 'src/main/runtime/agent-session-store-transactions.test.ts', 'src/main/runtime/agent-session-unsaved-creation-supersession.test.ts', 'src/main/runtime/automation-change-publication.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 c5dccffdcd7..fd3453fed65 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 @@ -98,7 +98,7 @@ describe('grouping a Codex thread restored from full history', () => { draws: 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-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 00000000000..90c94306db1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts @@ -0,0 +1,127 @@ +// 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' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' + +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({ + agents: NO_STRUCTURED_AGENTS, + 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 3389bc615e5..70019d6da3e 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( @@ -79,7 +93,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({ 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 00000000000..9a75c6b0ed5 --- /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-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index ac3e264ee0c..a36518c5e61 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 { @@ -360,6 +362,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 ce11c2dc34c..f3c8c5df9fe 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 @@ -45,8 +45,9 @@ 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 { hasUnfinishedStructuredAgentSessionWork, isInProgressStructuredAgentSessionItem, @@ -242,16 +243,9 @@ 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. - body: { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { - ...input.failureTextContext, - surface: 'row' - }), - tone: 'error' - }, - turnScope: runningRootTurnScope(items) + // that the provider stopped, and how Orca ended when the provider died with it. + body: orcaStopRowBody(input.failureTextContext, evidence.runtimeEnd), + 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 c336a1d3eca..ff53b6492a1 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 } 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 17441c67b93..8e072e714b6 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 @@ -29,6 +29,12 @@ import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' 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, @@ -162,6 +168,8 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( ) } const { close } = child + // Read before the kill: the turn a quit's stop may cut. + const quitCuts = 'quit' in ending && ending.quit ? runningRootTurnItemId(session.journal) : null try { if (context.restartWitness) { await snapshotBeforeStructuredAgentSessionStop( @@ -180,6 +188,22 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( new Error('provider child exit was not proven') ) } + // The exit handler has settled the turn by now, so the row reads its verdict. + if ('quit' in ending && ending.quit) { + const record = context.deps.store.getRecord(sessionId) + await recordStructuredAgentSessionShutdownCut({ + journal: session.journal, + sessionId, + fence: child.fence, + generation: child.generation ?? 'unknown', + turnItemId: quitCuts, + trigger: ending.quit, + ...(record + ? { failureTextContext: structuredAgentSessionFailureWordsContext(record) } + : {}), + logger: context.deps.logger + }) + } } finally { // A person's close binds what its child's end cut; done, proven or not, it binds no more. void close?.recorded.then((settle) => session.journal.stopMarks.settled(settle)) @@ -237,7 +261,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]) => session.child !== null) @@ -255,7 +280,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 1c9e2720aca..85629500a0b 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 0c9e59fba76..75907563959 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 @@ -174,7 +174,8 @@ export async function flushStructuredAgentSessionHost( stopped: context.restartResume.confirmStopped } }, - retainSessionIds + retainSessionIds, + context.trigger ), beginResumeMarkers: () => context.restartResume.beginTeardown(context.trigger), recordResumeMarkers: context.restartResume.recordMarkers, 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 00000000000..ee3f3901ea2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts @@ -0,0 +1,227 @@ +// 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, + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_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' +import { previousExitUnverifiableRefusal } from './structured-agent-session-child-close' + +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 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) + vi.spyOn(host.deps.recoveryCapsule!, 'list').mockRejectedValue(new Error('unreadable')) + + await host.restartResume.continueInterrupted(SESSION, turnItemId) + + 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' + }) + ) +}) + +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!)]) +}) + +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-interrupted-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts new file mode 100644 index 00000000000..49420832226 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.ts @@ -0,0 +1,119 @@ +// 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( + // 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) { + // 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', + 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-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index 9a8b5b62a8d..736e18c80b1 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 @@ -53,7 +53,8 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag await attach() // The attach's restart-offer withdrawal holds a lock file until it ends; snapshot after it. await hostTestRecoveryCapsuleSettled() - 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 @@ -81,7 +82,7 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag error: expect.objectContaining({ message: 'database disk image is malformed' }) }) await hostTestRecoveryCapsuleSettled() - expect(await readdir(root, { recursive: true })).toEqual(files) + expect((await readdir(root, { recursive: true })).toSorted()).toEqual(files) }) it.each([ 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 00000000000..74b5abe1bc3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row-readers.test.ts @@ -0,0 +1,104 @@ +// The row about Orca's own stop, as each kind of client reads it. A client that predates the cause +// prints today's row, red and on screen; this build names the cause, muted, and never folds it. + +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' +import { latestNativeChatOrcaStopCut } from '../../../shared/native-chat-orca-stop-cut' +import { nativeChatTurnFold } from '../../../shared/native-chat-turn-fold' +import { orcaStopRowBody } from './structured-agent-session-orca-stop-row' + +const LEGACY_TEXT = + 'Codex stopped while this response was in progress. You can continue in this conversation.' +const TURN = agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 1 }) + +function cutChat(row: AgentJournalRenderItem['body']): AgentJournalRenderItem[] { + const scope = { kind: 'turn' as const, turnItemId: TURN } + return [ + { + itemId: agentJournalItemKey({ provider: 'orca', clientMessageId: 'user-1' }), + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + }, + { + itemId: TURN, + revision: 2, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'cut', state: 'interrupted', startedAt: 1, completedAt: 5 } + }, + { + itemId: agentJournalItemKey({ provider: 'codex', threadId: 't', turnId: 'cut', ordinal: 2 }), + revision: 1, + sequence: 3, + observedAt: 3, + turnScope: scope, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Looking.' }] } + }, + { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'stale-session:session-1:shutdown-3-gen-1' + }), + revision: 1, + sequence: 4, + observedAt: 6, + turnScope: scope, + body: row + } + ] +} + +describe('the row a host writes for a cut it knows the cause of', () => { + const row = orcaStopRowBody({ agentName: 'Codex' }, 'update') + + it("keeps today's words and tone, and names its presentation and cause beside them", () => { + expect(row).toMatchObject({ + kind: 'status', + text: LEGACY_TEXT, + tone: 'error', + failure: { kind: 'providerExited' }, + presentation: 'orca-stop', + orcaStop: { cause: 'update' } + }) + }) + + it('reads, on a client that predates the cause, as the one row it prints today, on screen', () => { + const items = cutChat(row) + const read = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }) + expect(read).toBe(items) + expect(read.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : []))).toEqual([ + LEGACY_TEXT + ]) + // That client's fold reads only the stored tone: a red row is the turn's end, never folded. + const rows = read.map((item) => ({ + turnKey: item.itemId === read[0]!.itemId ? undefined : TURN, + role: item.body.kind === 'message' ? item.body.role : ('system' as const), + rendersProse: item.body.kind !== 'turn', + draws: true, + outlivesTurn: false, + reportsFailure: item.body.kind === 'status' && item.body.tone === 'error', + explainsTurn: false + })) + const { foldedRows } = nativeChatTurnFold({ + rows, + settledTurnKeys: new Set([TURN]), + expandedTurnKeys: new Set() + }) + expect(foldedRows.has(3)).toBe(false) + }) + + it('offers Continue on this build', () => { + expect(latestNativeChatOrcaStopCut(cutChat(row), [])).toEqual({ + turnItemId: TURN, + cause: 'update' + }) + }) + + it('names no presentation when the cause is unknown, so it reads as any owner death', () => { + expect(orcaStopRowBody({ agentName: 'Codex' }, undefined)).not.toHaveProperty('presentation') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts new file mode 100644 index 00000000000..1778f7e4397 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-orca-stop-row.ts @@ -0,0 +1,120 @@ +// 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 } from '../../../shared/agent-session-failure' +import { + AGENT_SESSION_ORCA_STOP_PRESENTATION, + isAgentSessionOrcaStopCause +} from '../../../shared/agent-session-orca-stop' +import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +import { + agentSessionFailureWords, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import type { + AgentJournalRenderItem, + AgentJournalStatusItem +} 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 + +/** The row about an owner gone from under a turn: today's words and tone, which every client prints + * as it always has, and why Orca stopped when it was Orca, which a client that knows the cause names + * instead, muted. */ +export function orcaStopRowBody( + context: AgentSessionFailureWordsContext | undefined, + orcaEnd: unknown +): AgentJournalStatusItem { + return { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + ...context, + surface: 'row' + }), + tone: 'error', + ...(isAgentSessionOrcaStopCause(orcaEnd) + ? { presentation: AGENT_SESSION_ORCA_STOP_PRESENTATION, orcaStop: { cause: orcaEnd } } + : {}) + } +} + +/** The root turn running as the quit stops the child: the one its stop may cut. */ +export function runningRootTurnItemId( + journal: Pick +): 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 }, + orcaStopRowBody(input.failureTextContext, input.trigger), + { 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 cfa2c7b502e..b9c2072e874 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 67006fd51d0..92d7dea95c4 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, @@ -85,10 +86,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 +98,7 @@ export function restartContinuationDeps( host.send({ ...input, beforeRun: () => { - if (!host.stillResumable(marker)) { + if (!stillWanted()) { throw new RestartContinuationSupersededError() } } @@ -154,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 @@ -197,6 +193,9 @@ 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. A refusal + * before acceptance is then the caller's to report, not a note: a retry adds none. */ + answerAtAcceptance?: true } /** A continuation handed to its agent, or already decided. */ @@ -206,13 +205,15 @@ 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, 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 { @@ -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 @@ -291,7 +294,7 @@ async function noteNotContinued( async function sendContinuation( deps: StructuredAgentSessionContinuationDeps, sessionId: string, - marker: AgentSessionResumeMarker, + marker: Pick, continuationId: string ): Promise { const fence = deps.currentFence(sessionId) @@ -325,54 +328,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. */ 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 4602630960a..c99cc945d31 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 b155674babf..89e039689a5 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 23d2e589776..0d73656b347 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, @@ -357,7 +358,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 00000000000..1cbbd8236ad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-contract.ts @@ -0,0 +1,49 @@ +// 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 { + StructuredAgentSessionRestartAudience, + 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: ( + audience?: StructuredAgentSessionRestartAudience + ) => Promise + /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ + listFailures: ( + audience?: StructuredAgentSessionRestartAudience + ) => Promise + /** Unnamed, continues every offer the audience sees; named, only those of them. */ + continueAfterRestart: ( + sessionIds: readonly string[] | undefined, + owner: string, + audience?: StructuredAgentSessionRestartAudience + ) => 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). An audience limits either to the agents it sees. */ + dismiss: ( + sessionIds?: readonly string[], + audience?: StructuredAgentSessionRestartAudience + ) => 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 6a868201baa..120aefb6f20 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 @@ -7,10 +7,7 @@ import { AgentSessionRefusalError, agentSessionRefusalFromReference } from '../../../shared/agent-session-wire-refusals' -import type { - AgentSessionResumeMarker, - AgentSessionResumeTrigger -} from '../../../shared/agent-session-resume-marker' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { createNewerOrcaChats, createStructuredAgentSessionRestartCandidateReaders @@ -24,21 +21,21 @@ import { createStructuredAgentSessionRestartOfferRecords } from './structured-ag import { restartRowsFor, type StructuredAgentSessionRestartAudience, - type StructuredAgentSessionResumeCandidate, - type StructuredAgentSessionResumeFailure + type StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' import { resumeStructuredAgentSessionsFromRestart, - StructuredAgentSessionResumeAdmission, - type StructuredAgentSessionResumeOutcome + StructuredAgentSessionResumeAdmission } 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 @@ -54,40 +51,7 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session- 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: ( - audience?: StructuredAgentSessionRestartAudience - ) => Promise - /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ - listFailures: ( - audience?: StructuredAgentSessionRestartAudience - ) => Promise - /** Unnamed, continues every offer the audience sees; named, only those of them. */ - continueAfterRestart: ( - sessionIds: readonly string[] | undefined, - owner: string, - audience?: StructuredAgentSessionRestartAudience - ) => 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). An audience limits either to the agents it sees. */ - dismiss: ( - sessionIds?: readonly string[], - audience?: StructuredAgentSessionRestartAudience - ) => 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: Pick< @@ -261,7 +225,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, audience, async (marker, continuationId) => { const started = await startStructuredAgentSessionContinuation( - restartContinuationDeps(continuationHost, marker), + continuationDeps(continuationHost, () => continuationHost.stillResumable(marker)), marker.sessionId, marker, continuationId @@ -327,6 +291,7 @@ export function createStructuredAgentSessionRestartResume( audience ), 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 00000000000..5c82a2185a5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts @@ -0,0 +1,267 @@ +// 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 { rm } from 'node:fs/promises' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnLifecycle +} from '../../../shared/agent-session-journal-types' +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 { + 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 { + 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' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' + +const CUT_TURN = { provider: 'codex' as const, threadId: THREAD, turnId: 'cut-turn', ordinal: 1 } +const LEGACY_TEXT = + 'Codex stopped while this response was in progress. You can continue in this conversation.' + +let host: StructuredAgentSessionHost +/** How the provider ends its open turn as it is stopped. */ +let providerEnd: Pick + +beforeEach(() => { + const state = hostTestState() + providerEnd = { state: 'interrupted', completedAt: HOST_TEST_NOW } + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + logger: createStructuredAgentSessionLogger(), + store: state.store, + adapter: { + ...adapter(), + closeSession: async () => { + 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', + 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() + beginAgentSessionRuntimeIncarnationForTest() + const store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: claudeAndCodexDeclared(), + 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' && readAgentSessionOrcaStop(item.body.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' }, + presentation: 'orca-stop', + orcaStop: { cause: trigger } + }) + expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId }) + // A client that reads no cause still prints exactly one row for the cut, in today's words. + expect(readerErrors).toEqual([LEGACY_TEXT]) + 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, + 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 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', () => { + 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/orcad/orcad-launch-contract.test.ts b/src/main/orcad/orcad-launch-contract.test.ts index f8f7b2c85fa..f1523476b47 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' @@ -124,6 +131,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 3bd3ad96889..8af240e4f91 100644 --- a/src/main/orcad/orcad-lifecycle.ts +++ b/src/main/orcad/orcad-lifecycle.ts @@ -5,6 +5,7 @@ import type { OrcadManagedStopInstance } from '../../shared/orcad-stop-request' 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 { ORCAD_SHUTDOWN_DEADLINE_MS } from './orcad-stop-deadlines' import { acquireProfileStateRuntimeAdmission, @@ -78,6 +79,8 @@ export async function startOrcadWithLifecycle( // Runtime resources stop in reverse registration order before any host resource. const runtime = new OrcadRuntimeLifetime() 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 runtime.stop() 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 00000000000..c17d69589b4 --- /dev/null +++ b/src/main/runtime/agent-session-runtime-attribution.test.ts @@ -0,0 +1,150 @@ +// 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 { 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' +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 { + beginAgentSessionRuntimeRecord, + 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 + }) + ) + ] + }) +} + +/** What that runtime recorded about itself: its start, and its end when it ended gracefully. */ +function recorded(runtime: string, end?: 'quit' | 'update') { + beginAgentSessionRuntimeRecord(directory, runtime, 1_000) + if (end) { + recordAgentSessionRuntimeEnd(end, 50_000) + } +} + +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 started and never ended', async () => { + await seedHeldBy('runtime-a') + recorded('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') + recorded('runtime-a', trigger) + expect(await restartEvidence()).toMatchObject({ runtimeEnd: trigger }) + } + ) + + it("is never told by another runtime's end", async () => { + await seedHeldBy('runtime-b') + recorded('runtime-b') + recorded('runtime-a', 'update') + expect(await restartEvidence()).toMatchObject({ runtimeEnd: 'crash' }) + }) + + 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 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') + }) + + 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') + 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({ + 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()) + // 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 new file mode 100644 index 00000000000..a3d255169a2 --- /dev/null +++ b/src/main/runtime/agent-session-runtime-attribution.ts @@ -0,0 +1,71 @@ +// 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, 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' +import type { AgentSessionStoreState } from './agent-session-store-state' + +/** 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 + } + // 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 } } } +} + +/** 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 00000000000..7121f5098b6 --- /dev/null +++ b/src/main/runtime/agent-session-runtime-end-record.test.ts @@ -0,0 +1,173 @@ +// 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 { 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 { 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, + recordAgentSessionRuntimeEndOnExit +} from './agent-session-runtime-end-record' +import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown' + +const RUNTIME_A = '0000000a-0000-4000-8000-000000000000' +const RUNTIME_B = '0000000b-0000-4000-8000-000000000000' + +let directory: string + +beforeEach(async () => { + writes.fail = false + directory = await mkdtemp(join(tmpdir(), 'orca-runtime-end-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +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, 'crash'], + [RUNTIME_B, 'quit'] + ]) + ) + }) + + 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('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) + expect(await readdir(runtimesDirectory())).toEqual([`${RUNTIME_A}.json`]) + }) +}) + +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() }, + 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') + + 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 new file mode 100644 index 00000000000..c57c593b4dc --- /dev/null +++ b/src/main/runtime/agent-session-runtime-end-record.ts @@ -0,0 +1,174 @@ +// 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. +// +// 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 { 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' + +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 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 }) + } +} + +/** 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, + now: number +): void { + const directory = join(stateDirectory, RUNTIMES_DIRECTORY) + const name = `${runtime}.json` + 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) + 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 { + // 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. + } + } +} + +function readTrigger(value: unknown): AgentSessionResumeTrigger | undefined { + return AGENT_SESSION_RESUME_TRIGGERS.find((trigger) => trigger === value) +} + +/** 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 end = parsed.end + return readTrigger( + typeof end === 'object' && end !== null && 'trigger' in end ? end.trigger : undefined + ) + } catch { + return undefined + } +} + +/** + * 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 { + const directory = join(stateDirectory, RUNTIMES_DIRECTORY) + let names: string[] + try { + names = readdirSync(directory) + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' ? new Map() : null + } + 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-state.ts b/src/main/runtime/agent-session-store-state.ts index 34d46a6bf1f..9f7e9ef3613 100644 --- a/src/main/runtime/agent-session-store-state.ts +++ b/src/main/runtime/agent-session-store-state.ts @@ -3,6 +3,7 @@ import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionOrcaStopCause } from '../../shared/agent-session-orca-stop' import type { AgentSessionTabTable } from './agent-session-tab-table' export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } @@ -15,4 +16,7 @@ export type AgentSessionStoreState = { unreadableRecords: Map /** Chat tab id → the conversation it shows; null until this store first records a tab. */ sessionTabs: AgentSessionTabTable | 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 } diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 778956762d2..3877da33af4 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -13,6 +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-store-state' import { writeAgentSessionStoreRows } from './agent-session-record-rows' +import { + agentSessionRuntimeIncarnation, + attributeAgentSessionRuntime +} from './agent-session-runtime-attribution' +import { + beginAgentSessionRuntimeRecord, + readAgentSessionRuntimeEnds +} from './agent-session-runtime-end-record' import { agentSessionStoreDraftRowWrites, draftAgentSessionStoreState, @@ -79,7 +87,16 @@ export class AgentSessionStoreTransactions { loaded: AgentSessionStoreState ) { freezeRows(loaded, null) - this.published = loaded + this.published = { + ...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. */ @@ -148,6 +165,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/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index 1d1ddfcc598..c28fe9856f6 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' import { structuredAgentsReadBy } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ @@ -68,6 +72,18 @@ 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. + // It names one chat the client shows, as a send does, so no agent audience applies. + 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 f55619350ef..1f749d50490 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -33,3 +33,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/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 370c0bb3f55..4bbd415cd02 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 () => { diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 3275939a4fa..bb0bf39ff7f 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -7,6 +7,7 @@ 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 { recordAgentSessionRuntimeEnd } from './agent-session-runtime-end-record' export type InstalledRuntime = { host: StructuredAgentSessionHost @@ -35,6 +36,9 @@ 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. + 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() diff --git a/src/main/update-install-exit-watchdog.test.ts b/src/main/update-install-exit-watchdog.test.ts index 80457230a3c..43fc6d369b4 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 69986d1154e..4517b5f41c5 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 469d8313830..417991f8a0d 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'] }) + + 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 41d84d1ab93..89c01dff078 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') }) } 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 00000000000..51ef0de7a6d --- /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 00000000000..c5f6d49af55 --- /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 105f541b87d..9111eae70db 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 { announceRestartResults, type RestartContinuationOutcome @@ -294,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[] } @@ -401,7 +404,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 new file mode 100644 index 00000000000..de80b08e4c0 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.test.tsx @@ -0,0 +1,266 @@ +// @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(() => { + 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, + launchPending: false + } +}) + +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', () => ({ + useStructuredAgentSessionHostCapabilityState: () => mocks.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 +})) + +import { TooltipProvider } from '@/components/ui/tooltip' +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { + NativeChatInterruptedContinue, + useNativeChatInterruptedContinuation +} 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', orcaStop: { cause: 'crash' } } + } +] + +type Props = { + journalItems?: readonly AgentJournalRenderItem[] + submissions?: readonly Pick[] + isWorking?: boolean + target?: RuntimeClientTarget +} + +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: { clearError: () => setComposerError(null) }, + target: props.target ?? PAIRED, + sessionId: 'session-1', + journalItems: props.journalItems ?? cutChat, + submissions: props.submissions ?? [], + isWorking: props.isWorking ?? false + }) + return ( + + {continuation.offeredTurnItemId ?? 'none'} + {String(continuation.view.continueAvailable)} + + {composerError ?? continuation.continueError?.text ?? 'none'} + + + + + ) +} + +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' + mocks.resuming = [] + mocks.launchPending = false + 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", () => { + mocks.call.mockResolvedValue({ outcome: 'pending' }) + render() + expect(screen.getByTestId('offered')).toHaveTextContent(TURN) + + fireEvent.click(continueButton()!) + + expect(mocks.call).toHaveBeenCalledExactlyOnceWith(PAIRED, 'agentSession.continueInterrupted', { + sessionId: 'session-1', + turnItemId: TURN + }) + // 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; the user continues by sending', () => { + 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() + }) + + 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() + cleanup() + render() + expect(continueButton()).toBeNull() + }) + + it('is not offered for a cut no Orca stop explains', () => { + render() + 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' }) + 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()) + expect(screen.getByTestId('error')).toHaveTextContent(FAILED) + }) + + it('says so and offers it again when the request fails', async () => { + mocks.call.mockRejectedValue(new Error('offline')) + render() + + fireEvent.click(continueButton()!) + + await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent(FAILED)) + 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')) + 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 new file mode 100644 index 00000000000..2e3e0be96de --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatInterruptedContinue.tsx @@ -0,0 +1,143 @@ +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 { useStructuredAgentSessionHostCapabilityState } 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/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' +import { useNativeChatLaunchResumePending } from '../native-chat-launch-resume-decision' +import type { NativeChatComposerNoticeContent } from './native-chat-composer-notice' + +type ContinueAnswer = { outcome?: string } + +export type NativeChatInterruptedContinuation = { + /** 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 + /** A Continue that did not go through, for the composer's notice card: shown while the chat + * still sits on that cut and gone once it is continued from anywhere. */ + continueError: (NativeChatComposerNoticeContent & { onDismiss: () => void }) | null + continueNow: () => void +} + +/** + * Continue, while the chat's latest turn is a reply an Orca stop cut off and nothing was sent since. + * 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 useNativeChatInterruptedContinuation(input: { + target: RuntimeClientTarget + sessionId: string + 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: { clearError: () => void } +}): NativeChatInterruptedContinuation { + const { target, sessionId } = input + const hostLabel = useStructuredAgentSessionHostLabel(target) + const capability = useStructuredAgentSessionHostCapabilityState( + target, + AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY + ) + // 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] + ) + // 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 + : null + const continueNow = (): void => { + if (offered === null) { + return + } + const turnItemId = offered + // Nothing was accepted: one line in the composer, which a retry replaces rather than repeats. + const failed = (): void => { + setAsked((current) => (current === turnItemId ? null : current)) + setFailedOn(turnItemId) + } + setAsked(turnItemId) + setFailedOn(null) + input.composer.clearError() + void callStructuredAgentSession(target, 'agentSession.continueInterrupted', { + sessionId, + turnItemId + }).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' + // 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 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, + continueError: failedHere ? { text: failedHere, onDismiss: () => setFailedOn(null) } : null, + continueNow + } +} + +export function NativeChatInterruptedContinue({ + continuation +}: { + continuation: NativeChatInterruptedContinuation +}): React.JSX.Element | null { + const explanationId = useId() + if (continuation.offeredTurnItemId === 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 083933547d5..db62bc280a7 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -5,18 +5,122 @@ 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 { + NativeChatOrcaStopContext, + type NativeChatOrcaStopView +} from './native-chat-orca-stop-context' afterEach(cleanup) -function renderStatus(body: AgentJournalStatusItem) { +function orcaStopView( + hostLabel: string | null, + continueAvailable: boolean +): NativeChatOrcaStopView { + return { hostLabel, continueAvailable } +} + +function renderStatus( + body: AgentJournalStatusItem, + hostLabel: string | null = null, + continueAvailable = false +) { 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, continueAvailable) 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', + presentation: 'orca-stop', + orcaStop: { cause } + } +} + +describe('the row an Orca stop leaves', () => { + it.each([ + ['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(`${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 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.' + ) + ).toBeInTheDocument() + }) + + // A client that re-words unnamed host rows keeps this row's presentation and cause, neutral. + it('stays neutral, naming the cause, as a reader that re-presented it neutral hands it on', () => { + // Its words and tone replaced, and the failure fact the old words came from dropped with them. + const represented: AgentJournalStatusItem = { + kind: 'status', + text: 'This response was interrupted. You can continue in this conversation.', + tone: 'notice', + presentation: 'orca-stop', + orcaStop: { cause: 'crash' } + } + renderStatus(represented, 'studio-mac', true) + expect( + screen.getByText( + 'Orca on studio-mac stopped unexpectedly while this response was in progress.' + ).parentElement?.parentElement + ).toHaveClass('text-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() + }) + + it('keeps the host words when the chat has no machine to name, muted all the same', () => { + renderStatus(orcaStopRow('update'), null) + expect(screen.getByText(LEGACY_TEXT).parentElement?.parentElement).toHaveClass( + 'text-muted-foreground' + ) + }) + + it('keeps the host words for a stop Orca did not cause', () => { + const { orcaStop: _orcaStop, presentation: _presentation, ...agentExit } = orcaStopRow('update') + renderStatus(agentExit, 'studio-mac') + expect(screen.getByText(LEGACY_TEXT).parentElement?.parentElement).toHaveClass( + 'text-destructive' + ) + }) +}) + 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 66ac58aeb75..1bd21e9ec01 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -11,6 +11,9 @@ import { type AgentSessionHostStatusPresentation } from '../../../../shared/agent-session-host-status-rows' 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> = { @@ -35,6 +38,7 @@ export function NativeChatNoticeRow({ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean }): React.JSX.Element { + const orcaStopView = useNativeChatOrcaStopView() if (block.presentation === 'compaction') { const label = translate('components.native-chat.notices.compaction', 'Context compacted') return ( @@ -87,7 +91,16 @@ export function NativeChatNoticeRow({ ) } - 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, continueAvailable } = orcaStopView + const named = orcaStop !== undefined && hostLabel !== null + const text = named + ? nativeChatOrcaStopRowText(orcaStop.cause, hostLabel, { continueAvailable }) + : block.text + const tone = + named || block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ? 'notice' : block.tone const Icon = tone === 'warning' ? AlertTriangle @@ -108,7 +121,7 @@ export function NativeChatNoticeRow({ >
{Icon ?
{block.providerFrame ? ( ( - () => ({ - 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 } : {}), - // Older pages can't load while the host is unreachable, so the row waits for it. - hasMore: controller.hasOlder && hostOutage === null, - loadingEarlier: controller.loadingOlder, - olderHistoryGeneration: controller.olderHistoryGeneration, - loadEarlier: controller.loadOlder, - readPhase: - controller.status === 'loading' - ? 'loading' - : controller.status === 'error' - ? 'error' - : 'ready' - }), - [controller, historyPhase, hostOutage, props.agent, props.sessionId] + const session = useStructuredChatLiveSession( + controller, + historyPhase, + props.sessionId, + props.agent, + hostOutage === null ) const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry) const { revealLatest } = submits const agentLabel = structuredAgentLabel(props.agent) + const continuation = useNativeChatInterruptedContinuation({ + target: props.target, + sessionId: props.sessionId, + journalItems: controller.journalItems, + submissions: controller.submissions, + isWorking: controller.isWorking, + composer: { clearError: () => reportComposerError(null) } + }) const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ pending: controller.pending, submissions: controller.submissions, @@ -204,7 +194,12 @@ export function NativeChatStructuredSession( const sessionError = viewState.kind === 'error' || !readFailure ? controller.error : readFailure.text const launch = { ...provisionalLaunch, retry: submits.retryLaunch } - const notices = structuredSessionNotices({ launch, agentLabel, sessionError, composerError }) + const notices = structuredSessionNotices({ + launch, + agentLabel, + sessionError, + composerError: composerError ?? continuation.continueError + }) return (
) : ( - + + + )}
{readFailedFinally ? null : ( <> + {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} ({ + hostLabel: null, + continueAvailable: false +}) + +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 00000000000..46e43e964ba --- /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. Where the host can continue a cut, Continue is the way on, so the row does + * not say it again; decided by the host, not the button, so the words never change on screen. */ +export function nativeChatOrcaStopRowText( + cause: AgentSessionOrcaStopCause, + machine: string, + options: { continueAvailable: boolean } +): string { + return options.continueAvailable + ? 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/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 63637a4146a..c03baec3a6c 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 @@ -265,6 +265,47 @@ 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] + ]) + // 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', () => { 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 287d0da1292..0aa0700842e 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 @@ -11,6 +11,7 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from '../../../../shared/agent-session-orca-stop' import { isBackgroundTaskBlock, isSubagentGroupBlock, @@ -149,9 +150,18 @@ export function buildNativeChatTranscriptSlots( isBackgroundTaskBlock(block) || isStoppedBeforeStartBlock(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/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 00000000000..39e540d50df --- /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/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 00000000000..1c3e8ad155a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-chat-live-session.ts @@ -0,0 +1,57 @@ +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'], + hostReachable: boolean +): 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 } : {}), + // Older pages can't load while the host is unreachable, so the row waits for it. + hasMore: controller.hasOlder && hostReachable, + loadingEarlier: controller.loadingOlder, + olderHistoryGeneration: controller.olderHistoryGeneration, + loadEarlier: controller.loadOlder, + readPhase: + controller.status === 'loading' + ? 'loading' + : controller.status === 'error' + ? 'error' + : 'ready' + }), + [controller, historyPhase, agent, sessionId, hostReachable] + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4f20fdc6025..2b087c53efd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18121,7 +18121,11 @@ "historyItemTooLarge": "This part of the chat was too large to show.", "stoppedBeforeStart": "Stopped before the agent started", "dismiss": "Dismiss", - "copyError": "Copy error" + "copyError": "Copy error", + "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.", @@ -18574,6 +18578,11 @@ "offline": "{{hostName}} is offline", "reconnecting": "{{hostName}} is reconnecting…", "reconnect": "Reconnect" + }, + "interruptedContinue": { + "continue": "Continue", + "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 cb64bd6a409..43265aa6f45 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18084,7 +18084,11 @@ "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla.", "stoppedBeforeStart": "Detenido antes de que el agente empezara", "dismiss": "Descartar", - "copyError": "Copiar error" + "copyError": "Copiar error", + "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" @@ -18140,6 +18144,11 @@ "offline": "{{hostName}} está sin conexión", "reconnecting": "{{hostName}} se está reconectando…", "reconnect": "Reconectar" + }, + "interruptedContinue": { + "continue": "Continuar", + "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 2107446803c..3fc2046a980 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18075,7 +18075,11 @@ "historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée.", "stoppedBeforeStart": "Arrêté avant le démarrage de l'agent", "dismiss": "Ignorer", - "copyError": "Copier l'erreur" + "copyError": "Copier l'erreur", + "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" @@ -18242,6 +18246,11 @@ "offline": "{{hostName}} est hors ligne", "reconnecting": "{{hostName}} se reconnecte…", "reconnect": "Reconnecter" + }, + "interruptedContinue": { + "continue": "Continuer", + "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 a85208f3e70..d6f74a559fe 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18009,7 +18009,11 @@ "historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。", "stoppedBeforeStart": "Agent の開始前に停止しました", "dismiss": "閉じる", - "copyError": "エラーをコピー" + "copyError": "エラーをコピー", + "orcaStopUpdate": "この応答の途中で、{{machine}} の Orca がアップデートのため再起動しました。", + "orcaStopQuit": "この応答の途中で、{{machine}} の Orca が終了しました。", + "orcaStopCrash": "この応答の途中で、{{machine}} の Orca が予期せず停止しました。", + "orcaStopCanContinue": "この会話で続行できます。" }, "providerFrame": { "byteLength": "{{value0}}バイト" @@ -18176,6 +18180,11 @@ "offline": "{{hostName}} はオフラインです", "reconnecting": "{{hostName}} に再接続しています…", "reconnect": "再接続" + }, + "interruptedContinue": { + "continue": "続行", + "failed": "このチャットを続行できませんでした。もう一度試すか、メッセージを送信してください。", + "explanation": "続行すると、Agent はまず最後の手順が完了したかどうかを確認します。" } }, "tab": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b9c2544fdf7..b7d15e012b2 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18009,7 +18009,11 @@ "historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다.", "stoppedBeforeStart": "에이전트가 시작되기 전에 중지됨", "dismiss": "닫기", - "copyError": "오류 복사" + "copyError": "오류 복사", + "orcaStopUpdate": "이 응답이 진행되는 동안 {{machine}}의 Orca가 업데이트를 위해 다시 시작되었습니다.", + "orcaStopQuit": "이 응답이 진행되는 동안 {{machine}}의 Orca가 종료되었습니다.", + "orcaStopCrash": "이 응답이 진행되는 동안 {{machine}}의 Orca가 예기치 않게 중지되었습니다.", + "orcaStopCanContinue": "이 대화에서 계속할 수 있습니다." }, "providerFrame": { "byteLength": "{{value0}}바이트" @@ -18176,6 +18180,11 @@ "offline": "{{hostName}}이(가) 오프라인입니다", "reconnecting": "{{hostName}}에 다시 연결하는 중…", "reconnect": "다시 연결" + }, + "interruptedContinue": { + "continue": "계속", + "failed": "이 채팅을 계속할 수 없습니다. 다시 시도하거나 메시지를 보내세요.", + "explanation": "계속하면 에이전트가 먼저 마지막 단계가 완료되었는지 확인합니다." } }, "tab": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c81b365558c..16e2d6f3d57 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17974,7 +17974,11 @@ "historyItemTooLarge": "聊天的这一部分过大,无法显示。", "stoppedBeforeStart": "在智能体开始前已停止", "dismiss": "关闭", - "copyError": "复制错误" + "copyError": "复制错误", + "orcaStopUpdate": "此回复进行期间,{{machine}} 上的 Orca 为更新而重新启动。", + "orcaStopQuit": "此回复进行期间,{{machine}} 上的 Orca 已关闭。", + "orcaStopCrash": "此回复进行期间,{{machine}} 上的 Orca 意外停止。", + "orcaStopCanContinue": "你可以在此对话中继续。" }, "providerFrame": { "byteLength": "{{value0}} 字节" @@ -18141,6 +18145,11 @@ "offline": "{{hostName}} 已离线", "reconnecting": "正在重新连接 {{hostName}}…", "reconnect": "重新连接" + }, + "interruptedContinue": { + "continue": "继续", + "failed": "无法继续此聊天。请重试,或发送一条消息。", + "explanation": "继续后,代理会先检查其上一步是否已完成。" } }, "tab": { diff --git a/src/shared/agent-session-account-home.ts b/src/shared/agent-session-account-home.ts index f33f7034758..1f899edad82 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}$/ +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. */ +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-continue-interrupted-capability.ts b/src/shared/agent-session-continue-interrupted-capability.ts new file mode 100644 index 00000000000..359e4c5ad12 --- /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/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 3cbb259af36..788a5fe68c9 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -246,7 +246,8 @@ const KnownItemBody = z.discriminatedUnion('kind', [ turnLifecycle: z.object(TurnLifecycleFields).optional(), providerFrame: ProviderFrame.optional(), threadGoal: AgentJournalThreadGoalStateSchema.optional(), - failure: AgentSessionFailureFactSchema.optional() + failure: AgentSessionFailureFactSchema.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 c335e2f857f..26dae9d57fa 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -326,6 +326,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 00000000000..f3d45282648 --- /dev/null +++ b/src/shared/agent-session-orca-stop.ts @@ -0,0 +1,20 @@ +// 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 +/** 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 } + +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 395fff13adf..c228335a626 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -28,7 +28,11 @@ import { isAgentSessionProviderHandleInNamespace, isStructuredAgentId } from './agent-session-provider-handle-encoding' -import type { AgentSessionAccountHome } from './agent-session-account-home' +import { + isAgentSessionAccountHome, + MAX_PATH_LENGTH, + type AgentSessionAccountHome +} from './agent-session-account-home' export type { AgentSessionAccountHome } from './agent-session-account-home' @@ -71,6 +75,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 } @@ -93,6 +100,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 + /** 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 } export type AgentSessionLease = { @@ -163,7 +175,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}$/ @@ -228,23 +239,8 @@ export function isAgentSessionProcessIdentity( (identity.processStartTimeMs === null || (Number.isSafeInteger(identity.processStartTimeMs) && (identity.processStartTimeMs as number) >= 0)) && - isBoundedString(identity.spawnToken, MAX_ID_LENGTH) - ) -} - -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) + isBoundedString(identity.spawnToken, MAX_ID_LENGTH) && + (identity.runtime === undefined || isBoundedString(identity.runtime, MAX_ID_LENGTH)) ) } @@ -296,7 +292,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' || @@ -309,7 +305,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 00000000000..728b4a0d50a --- /dev/null +++ b/src/shared/native-chat-orca-stop-cut.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +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, AgentJournalTurnOutcome } 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?: AgentJournalTurnOutcome): 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: today's words and fact, and the + * cause beside them. */ +function stopRow(orcaStop: { cause: string } | undefined, scoped = true): AgentJournalRenderItem { + return { + itemId: agentJournalItemKey({ + provider: 'orca', + clientMessageId: orcaShutdownRowClientMessageId('session-1', 3, 'gen-1') + }), + revision: 1, + sequence: 3, + observedAt: 6, + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + agentName: 'Codex', + surface: 'row' + }), + tone: 'error', + ...(orcaStop === undefined ? {} : { orcaStop }) + }, + ...(scoped ? { turnScope: { kind: 'turn' as const, turnItemId: turnId } } : {}) + } +} + +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(readAgentSessionOrcaStop(update)).toEqual({ cause: 'update' }) + expect(readAgentSessionOrcaStop({ cause: 'power-loss' })).toBeUndefined() + }) + + 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", () => { + 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) + }) +}) + +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(update)], [])).toEqual({ + turnItemId: turnId, + cause: 'update' + }) + }) + + it('is gone once a message follows the cut', () => { + expect( + latestNativeChatOrcaStopCut([userMessage(1), cutTurn(), stopRow(update), userMessage(4)], []) + ).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( + [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(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 new file mode 100644 index 00000000000..cab5825ea6e --- /dev/null +++ b/src/shared/native-chat-orca-stop-cut.ts @@ -0,0 +1,88 @@ +// 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 { 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' +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 + * 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 { + return item.body.kind === 'status' + ? (readAgentSessionOrcaStop(item.body.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 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[], + submissions: readonly Pick[] +): NativeChatOrcaStopCut | null { + 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) || + isStructuredAgentSessionNonRequestRow(item, commandTurns) + ) { + 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/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index ef35a3074f5..c652240db18 100644 --- a/src/shared/native-chat-turn-fold.test.ts +++ b/src/shared/native-chat-turn-fold.test.ts @@ -13,7 +13,7 @@ function row(overrides: Partial = {}): NativeChatTurnFold draws: true, outlivesTurn: false, reportsFailure: false, - reportsCompaction: false, + explainsTurn: false, ...overrides } } @@ -214,9 +214,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 fdb7af00b47..59687f3d789 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -27,9 +27,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 = { @@ -87,13 +88,14 @@ 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.draws || row.outlivesTurn || - row.reportsCompaction || + row.explainsTurn || !settledTurnKeys.has(turnKey) ) { continue diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 3f93a97ad3b..40487d01813 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, @@ -63,6 +64,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/protocol-version.ts b/src/shared/protocol-version.ts index f600123fd29..225b9d23364 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -28,6 +28,7 @@ export { } from './orchestration-runtime-capabilities' 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' import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' export { AGENT_SESSION_CURSOR_RESUME_RUNTIME_CAPABILITY, @@ -394,6 +395,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_SURFACE_RUNTIME_CAPABILITIES, ...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES, AGENT_SESSION_REWIND_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 d425c20d38d..ce0481c1f17 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -487,6 +487,7 @@ import { SpeechModelAction } from './speech-params' import { SshTarget } from './ssh-params' +import { ContinueInterruptedParams } from './structured-agent-session-continue-params' import { AcknowledgeAttentionParams, AgentsParams, @@ -609,6 +610,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-continue-params.ts b/src/shared/rpc-contract/structured-agent-session-continue-params.ts new file mode 100644 index 00000000000..fed0bc550a8 --- /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/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 921d90d5cab..be8bd69ddc4 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -39,6 +39,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). */ @@ -54,8 +67,7 @@ export function latestStructuredAgentSessionRequest( if ( !item || !isRootAgentJournalItem(item) || - commandTurns.has(item.itemId) || - isStructuredAgentSessionCommandEntry(item.body) + isStructuredAgentSessionNonRequestRow(item, commandTurns) ) { continue } @@ -70,12 +82,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, diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index d82ec84f6f5..dd5737d1c54 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -133,7 +133,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 58dbbe9131c..080f3c5de53 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 } : {}) + } + } + : {}) } } 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 01166485440..949457d4f63 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 @@ -24,6 +24,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 @@ -116,7 +117,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 c94fa7f1c03..f1c62f3d94e 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 @@ -170,6 +170,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', @@ -345,6 +351,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 } }